refactor(vectorcraft): move main.rs items to {app,host_services,prefs,titlebar}.rs

This commit is contained in:
aiongg
2026-10-26 22:01:24 +00:00
parent c50026fc86
commit 7bbf803f7e
5 changed files with 744 additions and 712 deletions
+166
View File
@@ -0,0 +1,166 @@
use super::prefs::save_prefs;
use super::*;
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
/// button's secondary click on Windows. The control channel's input and the native menu's keys
/// go to the main window only.
#[cfg(target_os = "macos")]
pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
}
#[cfg(all(windows, not(target_vendor = "win7")))]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
}
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
None
}
impl eframe::App for App {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
self.app.finish_ime_logic(ctx);
}
/// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the
/// app's raw-input hook while the keyboard user is unconfined.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
window_frame();
}
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
self.app.status(if self.graphics_lost {
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
} else {
"The graphics device was lost: save your documents and restart VectorCraft"
});
}
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
// The window can't show the Save Changes question: it closes, and the next launch
// offers the changes back.
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
let files = open_documents::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
open_files(&mut self.app, files);
}
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
} else {
self.pending_maximize = false;
}
}
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
/// failure then starts the app again on another adapter (#651).
#[allow(unused_variables)]
fn end_before_teardown(frames: u64) {
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
if frames >= gpu::STARTUP_FRAMES {
log::info!("quitting");
log::logger().flush();
std::process::exit(0);
}
}
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn bring_window_forward(ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) {
eprintln!("vectorcraft: {f}: {e}");
}
}
}
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
#[cfg(target_os = "macos")]
fn discard_marked_text() {
if let Some(mtm) = objc2::MainThreadMarker::new()
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
{
ic.discardMarkedText();
}
}
+326
View File
@@ -0,0 +1,326 @@
use super::*;
/// The window file dialogs belong to.
pub(crate) type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
}
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
/// the portal can't.
#[cfg(not(target_os = "macos"))]
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
{
return answer;
}
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request, parent)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
#[cfg(target_os = "macos")]
fn reveal_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("open");
c.args(["-R", path]);
c
}
#[cfg(windows)]
fn reveal_command(path: &str) -> std::process::Command {
use std::os::windows::process::CommandExt as _;
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
// and needs backslashes.
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
c
}
#[cfg(not(any(target_os = "macos", windows)))]
fn reveal_command(path: &str) -> std::process::Command {
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let mut c = std::process::Command::new("xdg-open");
c.arg(folder);
c
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
let parent = parent.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
})
.ok()?;
make_folder(&request);
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
// A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764).
#[cfg(target_os = "linux")]
let x11 = parent
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).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()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
})),
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
c
};
#[cfg(target_os = "macos")]
let mut c = std::process::Command::new("open");
#[cfg(not(any(target_os = "macos", windows)))]
let mut c = std::process::Command::new("xdg-open");
#[cfg(not(windows))]
c.arg(path);
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
+25 -712
View File
@@ -65,6 +65,31 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
mod app;
mod host_services;
mod prefs;
mod titlebar;
#[cfg(all(test, feature = "wgpu"))]
use app::bring_window_forward;
use app::native_input_hook;
use app::open_files;
use host_services::services;
#[cfg(all(test, feature = "wgpu"))]
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
use prefs::{load_prefs, log_dir, prefs_enabled, prefs_path, read_prefs};
#[cfg(all(test, feature = "wgpu", any(target_os = "macos", target_os = "windows")))]
use titlebar::CUSTOM_TITLEBAR;
#[cfg(feature = "wgpu")]
use titlebar::adapter_summary;
#[cfg(all(test, feature = "wgpu"))]
use titlebar::custom_titlebar_from;
#[cfg(all(test, feature = "wgpu"))]
use titlebar::opted_into_system_title_bar;
#[cfg(windows)]
use titlebar::set_taskbar_icon;
use titlebar::{custom_titlebar, native_options};
struct App {
app: VectorcraftApp,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
@@ -83,718 +108,6 @@ struct App {
maximize_tries: u8,
}
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
/// button's secondary click on Windows. The control channel's input and the native menu's keys
/// go to the main window only.
#[cfg(target_os = "macos")]
fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
}
#[cfg(all(windows, not(target_vendor = "win7")))]
fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
}
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
None
}
impl eframe::App for App {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
self.app.finish_ime_logic(ctx);
}
/// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the
/// app's raw-input hook while the keyboard user is unconfined.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
window_frame();
}
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frames = ctx.cumulative_frame_nr();
if let Some(why) = self.graphics_loss.take() {
// eframe can't give a window a new device: the user saves and starts again.
self.graphics_lost = self.app.graphics_lost(&why);
self.app.status(if self.graphics_lost {
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
} else {
"The graphics device was lost: save your documents and restart VectorCraft"
});
}
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
// The window can't show the Save Changes question: it closes, and the next launch
// offers the changes back.
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
let files = open_documents::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
open_files(&mut self.app, files);
}
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
} else {
self.pending_maximize = false;
}
}
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
/// failure then starts the app again on another adapter (#651).
#[allow(unused_variables)]
fn end_before_teardown(frames: u64) {
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
if frames >= gpu::STARTUP_FRAMES {
log::info!("quitting");
log::logger().flush();
std::process::exit(0);
}
}
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn bring_window_forward(ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
if let Err(e) = vectorcraft_ui_egui::io::open_reporting(app, &f) {
eprintln!("vectorcraft: {f}: {e}");
}
}
}
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
#[cfg(target_os = "macos")]
fn discard_marked_text() {
if let Some(mtm) = objc2::MainThreadMarker::new()
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
{
ic.discardMarkedText();
}
}
/// 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_dir::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_dir::prefs_path_for("DrawCraft", "drawcraft")
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
/// A file read only in part, or with layouts converted from an older version, is copied beside
/// itself before anything saves over it.
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
{
let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes);
if read.as_ref().is_none_or(|read| !read.intact) {
keep_prefs_copy(&p, &bytes, "ui.unreadable");
}
if read.as_ref().is_some_and(|read| read.converted) {
keep_prefs_copy(&p, &bytes, "ui.before-layout-migration");
}
return read.map(|read| read.ui);
}
let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?;
decode_saved_prefs(&bytes)
}
/// Saved preferences, with what this version cannot read of their docking data dropped and
/// older layouts converted (`vectorcraft_ui_egui::layout_migration`).
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui)
}
/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is
/// then left as it is rather than overwritten.
static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Copy `ui.json` beside it as `<stem>.json` or the first free `<stem>-N.json`, before the next
/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may
/// have written it), `ui.before-layout-migration` when its layouts were converted.
fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) {
use std::io::Write as _;
for n in 1..100u32 {
let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") };
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
Ok(mut file) => {
if file.write_all(bytes).is_ok() {
return;
}
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => break,
}
}
KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = prefs_bytes(app) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
}
}
/// The preferences file's contents: the UI state with the engine preferences.
fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_vec_pretty(&ui)
}
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// What rfd's blocking and asynchronous file dialogs are both set up with.
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
macro_rules! dialog_settings {
($($dialog:ty),*) => {$(
impl DialogSettings for $dialog {
fn parent(self, window: &winit::window::Window) -> Self {
self.set_parent(window)
}
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
self.set_directory(folder)
}
fn file_name(self, name: &str) -> Self {
self.set_file_name(name)
}
}
)*};
}
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
}
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.file_name(&pick.name) }
}
/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the
/// window and keeps it in front).
#[cfg(not(target_os = "macos"))]
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
match parent {
Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request),
None => dialog_for(rfd::FileDialog::new(), request),
}
}
/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet.
fn make_folder(request: &PickRequest) {
if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request {
let _ = std::fs::create_dir_all(folder);
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
/// the portal can't.
#[cfg(not(target_os = "macos"))]
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
{
return answer;
}
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(_) => dialog.save_file().into_iter().collect(),
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request, parent)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]);
/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing.
#[cfg(target_os = "macos")]
fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only");
Vec::new()
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
#[cfg(target_os = "macos")]
fn reveal_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("open");
c.args(["-R", path]);
c
}
#[cfg(windows)]
fn reveal_command(path: &str) -> std::process::Command {
use std::os::windows::process::CommandExt as _;
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
// and needs backslashes.
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
c
}
#[cfg(not(any(target_os = "macos", windows)))]
fn reveal_command(path: &str) -> std::process::Command {
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let mut c = std::process::Command::new("xdg-open");
c.arg(folder);
c
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
#[cfg(not(target_os = "macos"))]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
let parent = parent.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
})
.ok()?;
Some(rx)
}
/// The paths a dialog shown as a sheet picks, once it closes.
#[cfg(target_os = "macos")]
type SheetAnswer = std::pin::Pin<Box<dyn std::future::Future<Output = Vec<String>> + Send>>;
/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver
/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an
/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered
/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet
/// returns at once and the window keeps running; a thread of its own waits for the answer.
#[cfg(target_os = "macos")]
fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
use winit::raw_window_handle::HasWindowHandle as _;
// rfd runs the panel modally after all unless it has the running app's window to hold the
// sheet (and from another thread it can't reach the window): no dialog then (`pick_now`).
let mtm = objc2::MainThreadMarker::new();
let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning());
let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else {
log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some());
return None;
};
// The thread first, so the answer of a sheet once shown always has somewhere to go.
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<SheetAnswer>();
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(pollster::block_on(answer));
ui.request_repaint();
}
})
.ok()?;
make_folder(&request);
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
PickRequest::Open(_) => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::OpenMany => {
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
};
// The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled.
let _ = sheet_tx.send(answer);
Some(rx)
}
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
// A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764).
#[cfg(target_os = "linux")]
let x11 = parent
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).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()),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
})),
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
c
};
#[cfg(target_os = "macos")]
let mut c = std::process::Command::new("open");
#[cfg(not(any(target_os = "macos", windows)))]
let mut c = std::process::Command::new("xdg-open");
#[cfg(not(windows))]
c.arg(path);
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// 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 shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
#[cfg(windows)]
fn set_taskbar_icon(w: &winit::window::Window) {
use winit::platform::windows::WindowExtWindows as _;
let icon = app_icon();
// An icon that can't be made leaves the generic one: nothing else depends on it.
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
w.set_taskbar_icon(Some(i));
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
}
/// 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.
///
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
}
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
/// Windows draws its own caption unless the user opted into the system title bar.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
let opted_into_system = opted_into_system_title_bar(saved);
#[cfg(target_os = "linux")]
{
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
// present, the same two conditions the launch path checks below — that run is effectively
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
// the probe's own decision (app-drawn on GNOME) must stand.
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
if choice.overridden().is_none() && forced_to_x11 {
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
}
}
custom_titlebar_from(choice.frame(), opted_into_system)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.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"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
const USAGE: &str = "\
Usage: vectorcraft [options] [files…]
+107
View File
@@ -0,0 +1,107 @@
use super::*;
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
pub(crate) fn prefs_path() -> Option<std::path::PathBuf> {
prefs_dir::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_dir::prefs_path_for("DrawCraft", "drawcraft")
}
/// Where the log files live: `logs` in the preferences folder (see `logging`).
pub(crate) fn log_dir() -> Option<std::path::PathBuf> {
Some(prefs_path()?.parent()?.join("logs"))
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
pub(crate) fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
/// A file read only in part, or with layouts converted from an older version, is copied beside
/// itself before anything saves over it.
pub(crate) fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
{
let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes);
if read.as_ref().is_none_or(|read| !read.intact) {
keep_prefs_copy(&p, &bytes, "ui.unreadable");
}
if read.as_ref().is_some_and(|read| read.converted) {
keep_prefs_copy(&p, &bytes, "ui.before-layout-migration");
}
return read.map(|read| read.ui);
}
let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?;
decode_saved_prefs(&bytes)
}
/// Saved preferences, with what this version cannot read of their docking data dropped and
/// older layouts converted (`vectorcraft_ui_egui::layout_migration`).
pub(crate) fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui)
}
/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is
/// then left as it is rather than overwritten.
pub(crate) static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Copy `ui.json` beside it as `<stem>.json` or the first free `<stem>-N.json`, before the next
/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may
/// have written it), `ui.before-layout-migration` when its layouts were converted.
pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) {
use std::io::Write as _;
for n in 1..100u32 {
let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") };
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
Ok(mut file) => {
if file.write_all(bytes).is_ok() {
return;
}
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => break,
}
}
KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
pub(crate) fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = prefs_bytes(app) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
}
}
/// The preferences file's contents: the UI state with the engine preferences.
pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_vec_pretty(&ui)
}
+120
View File
@@ -0,0 +1,120 @@
use super::*;
/// 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 shows a window's big icon (`ICON_BIG`) in the taskbar and Alt+Tab; eframe sets only the
/// small one (the title bar's), so the taskbar showed a generic icon. Set the big one too.
#[cfg(windows)]
pub(crate) fn set_taskbar_icon(w: &winit::window::Window) {
use winit::platform::windows::WindowExtWindows as _;
let icon = app_icon();
// An icon that can't be made leaves the generic one: nothing else depends on it.
if let Ok(i) = winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
w.set_taskbar_icon(Some(i));
}
}
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
pub(crate) fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
}
/// 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.
///
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
}
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
/// Windows draws its own caption unless the user opted into the system title bar.
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
let opted_into_system = opted_into_system_title_bar(saved);
#[cfg(target_os = "linux")]
{
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
// present, the same two conditions the launch path checks below — that run is effectively
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
// the probe's own decision (app-drawn on GNOME) must stand.
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
if choice.overridden().is_none() && forced_to_x11 {
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
}
}
custom_titlebar_from(choice.frame(), opted_into_system)
}
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.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"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}