diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs new file mode 100644 index 000000000..c7d6ada32 --- /dev/null +++ b/apps/vectorcraft/src/app.rs @@ -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 { + 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 { + 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 { + 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) { + 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(); + } +} diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs new file mode 100644 index 000000000..aa7d7f42c --- /dev/null +++ b/apps/vectorcraft/src/host_services.rs @@ -0,0 +1,326 @@ +use super::*; + +/// The window file dialogs belong to. +pub(crate) type Parent = Option>; + +/// 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: 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 { + 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 { + 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 { + 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>> { + 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> + 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>> { + 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::(); + 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}")) +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 76b18e515..2cdf80b42 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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 { - 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 { - 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 { - 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) { - 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 { - 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 { - prefs_dir::prefs_path_for("DrawCraft", "drawcraft") -} - -/// Where the log files live: `logs` in the preferences folder (see `logging`). -fn log_dir() -> Option { - 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 { - 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::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 `.json` or the first free `-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) { - 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> { - 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>; - -/// 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: 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 { - 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 { - 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 { - 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>> { - 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> + 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>> { - 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::(); - 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…] diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs new file mode 100644 index 000000000..67a5a4a08 --- /dev/null +++ b/apps/vectorcraft/src/prefs.rs @@ -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 { + 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 { + 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 { + 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 { + 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::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 `.json` or the first free `-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) { + 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> { + let mut ui = app.ui.clone(); + ui.engine_prefs = app.session.prefs.to_json(); + serde_json::to_vec_pretty(&ui) +} diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs new file mode 100644 index 000000000..3d538ec88 --- /dev/null +++ b/apps/vectorcraft/src/titlebar.rs @@ -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() + } +}