From 90788b632afd76f9e3232346f0a62f8b0928a3cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 09:50:51 -0700 Subject: [PATCH 01/23] Linux: VectorCraft draws its own title bar on every desktop unless System Title Bar is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers the window kept the desktop's frame, and the app bar with its brand mark sat below it. Where the desktop draws a title bar, as KDE Plasma does, the window showed two. When the desktop's bar was hidden through the window manager, the app bar left behind did not move the window and had no caption buttons. On Linux and the BSDs VectorCraft now draws its own title bar on every desktop unless Preferences › User Interface › System Title Bar is on, as PhotoCraft does. The craft-window probe no longer picks the frame. VECTORCRAFT_TITLEBAR decides for the run it is set in: app draws VectorCraft's bar and system or desktop keeps the desktop's frame, also when the preference says otherwise. Before, System Title Bar on gave the desktop's frame even with app. macOS and Windows are unchanged. When the variable keeps the desktop's frame, the app bar leaves out the brand mark, as it does with System Title Bar. While the variable is set, Preferences dims System Title Bar and says that the variable chooses the title bar; the dialog's __titlebarFromEnv field is true. The note has a row in the 24 catalogs. Docs: docs/control-protocol.md, docs/development.md, docs/target-app-parity.md. --- apps/vectorcraft/src/main.rs | 126 +++++++++++++--------------- crates/ui-egui/src/chrome.rs | 24 ++++-- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/prefs_dialog.rs | 70 +++++++++++++++- docs/control-protocol.md | 6 +- docs/development.md | 2 +- docs/target-app-parity.md | 1 + 31 files changed, 178 insertions(+), 79 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index ee4232dc3..a92cda204 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -705,11 +705,11 @@ 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`). +/// Windows: 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. +/// Used on macOS and Windows; Linux and the BSDs decide in [`custom_titlebar_from`], where +/// VectorCraft also draws its own title bar by default. #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); @@ -720,60 +720,42 @@ fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> 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. +/// Whether this start draws its own title bar, given `VECTORCRAFT_TITLEBAR` (`overridden`, as +/// [`craft_window::Choice::overridden`] reads it) 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. +/// Linux and the BSDs: VectorCraft draws its own title bar on every desktop (GNOME, KDE, Sway, +/// Hyprland, COSMIC, X11 window managers) unless System Title Bar is on, as PhotoCraft does. The +/// variable decides for one run when it is set: `app` draws VectorCraft's bar and `system` or +/// `desktop` keeps the desktop's frame, whatever the preference says. The desktop the +/// `craft-window` probe finds does not change the result. #[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 +fn custom_titlebar_from(overridden: Option, opted_into_system: bool) -> bool { + overridden.unwrap_or(!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. +/// macOS and Windows: decided by [`CUSTOM_TITLEBAR`] and the preference; `VECTORCRAFT_TITLEBAR` +/// is not read. 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 { +fn custom_titlebar_from(_overridden: Option, 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); - } +/// What `VECTORCRAFT_TITLEBAR` asks for on Linux and the BSDs: `Some(true)` VectorCraft's own title +/// bar, `Some(false)` the desktop's frame, `None` when it is unset or holds another value. Always +/// `None` on macOS and Windows. The `craft-window` probe runs once per process. +fn titlebar_override() -> Option { + static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); + if cfg!(any(target_os = "macos", target_os = "windows")) { + return None; } - custom_titlebar_from(choice.frame(), opted_into_system) + *OVERRIDE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")).overridden()) +} + +/// Whether this start draws its own title bar: [`custom_titlebar_from`] with `VECTORCRAFT_TITLEBAR` +/// and the saved System Title Bar preference. Call once at startup, before [`native_options`]. +fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + custom_titlebar_from(titlebar_override(), opted_into_system_title_bar(saved)) } fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { @@ -1128,6 +1110,7 @@ fn main() -> std::process::ExitCode { app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into()); } app.custom_titlebar = custom_titlebar; + app.titlebar_from_env = titlebar_override().is_some(); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx).with_automation_roots(roots.clone()); @@ -1314,8 +1297,6 @@ mod tests { #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { - use craft_window::{Frame, Layout}; - // The System Title Bar preference (Preferences › User Interface) is read the same way on // every platform; a missing or unreadable value is `false`. assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); @@ -1324,33 +1305,40 @@ mod tests { let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); assert!(super::opted_into_system_title_bar(Some(&on))); - // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title - // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native - // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws - // our own title bar — unless the user opted into the system title bar, which forces the - // native frame back on. - let app = Frame::App(Layout::default()); - let desktop = Frame::Desktop; - #[cfg(not(any(target_os = "macos", target_os = "windows")))] - { - assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); - assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); - assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); - assert!(!super::custom_titlebar_from(&desktop, true)); - } - // macOS and Windows keep today's behavior, independent of the probed frame: the pref still - // forces the native frame back on where a custom one would otherwise be drawn. + // macOS and Windows: `VECTORCRAFT_TITLEBAR` is not read, and the preference turns the + // native frame back on where VectorCraft would draw its own. #[cfg(any(target_os = "macos", target_os = "windows"))] { - assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); - assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); - assert!(!super::custom_titlebar_from(&desktop, true)); + assert_eq!(super::titlebar_override(), None); + for overridden in [None, Some(true), Some(false)] { + assert_eq!(super::custom_titlebar_from(overridden, false), super::CUSTOM_TITLEBAR, "{overridden:?}"); + assert!(!super::custom_titlebar_from(overridden, true), "{overridden:?}"); + } } assert_eq!(super::native_options(false).viewport.decorations, Some(true)); assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } + /// Linux and the BSDs: VectorCraft's own title bar on every desktop unless System Title Bar is + /// on, and `VECTORCRAFT_TITLEBAR` decides over the preference for the run it is set in. + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[test] + fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { + // What the `craft-window` probe reports: the desktop's frame on KDE Plasma, Sway, Hyprland, + // COSMIC and X11, the app's on GNOME's Wayland session; none of them sets the override. + for desktop in ["GNOME Wayland", "KDE Plasma", "Sway", "Hyprland", "COSMIC", "X11 window manager", "GNOME on X11"] { + assert!(super::custom_titlebar_from(None, false), "{desktop}: VectorCraft's own title bar"); + assert!(!super::custom_titlebar_from(None, true), "{desktop}: System Title Bar keeps the desktop's frame"); + } + // VECTORCRAFT_TITLEBAR=app draws VectorCraft's bar and =system (or =desktop) keeps the + // desktop's frame, whatever the preference says. + for opted_into_system in [false, true] { + assert!(super::custom_titlebar_from(Some(true), opted_into_system), "app, preference {opted_into_system}"); + assert!(!super::custom_titlebar_from(Some(false), opted_into_system), "system, preference {opted_into_system}"); + } + } + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5eeda2590..e8340df52 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -111,12 +111,13 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } /// Whether the window shows the system's title bar instead of the app drawing its own: Windows -/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system -/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor -/// does the web build (the browser tab has no document title), nor an embedder that never -/// turned the preference on. +/// and Linux with Preferences › User Interface › System Title Bar, and Linux when +/// `VECTORCRAFT_TITLEBAR` kept the desktop's frame ([`VectorcraftApp::titlebar_from_env`]). macOS +/// always has system decorations, but keeps the in-app brand mark, so it never counts as system +/// mode here; nor does the web build (the browser tab has no document title), nor an embedder +/// that never turned the preference on. pub fn system_title_bar(app: &VectorcraftApp) -> bool { - !app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32")) + !app.custom_titlebar && (app.session.prefs.system_title_bar || app.titlebar_from_env) && !cfg!(any(target_os = "macos", target_arch = "wasm32")) } /// The OS window title: the active document's name suffixed with the app name (a `*` prefix @@ -2216,6 +2217,19 @@ mod tests { } } + #[test] + fn a_desktop_frame_from_the_environment_variable_is_system_mode() { + // VECTORCRAFT_TITLEBAR=system with System Title Bar off: the desktop draws the title bar. + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.titlebar_from_env = true; + assert!(!app.session.prefs.system_title_bar); + assert_eq!(super::system_title_bar(&app), !cfg!(target_os = "macos")); + // VECTORCRAFT_TITLEBAR=app: VectorCraft draws it, whatever the preference says. + app.custom_titlebar = true; + app.run("prefs.set", json!({"key": "systemTitleBar", "value": true})).unwrap(); + assert!(!super::system_title_bar(&app)); + } + #[test] fn sync_window_title_sends_only_on_change() { let mut app = title_test_app(true); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f4f5b1fc1..197ba640b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -190,6 +190,7 @@ font-technology Variable Променливи Appearance panel Панел „Външен вид“ Applies when the linked file is relinked or updated. Прилага се, когато свързаният файл бъде пресвързан или актуализиран. Applies the next time VectorCraft starts. Прилага се при следващото стартиране на VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента. Apply Прилагане Apply Brush Прилагане на четка Apply Character Style Прилагане на стил на знаци diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 07c73f238..094e937d5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3890,6 +3890,7 @@ font-technology Variable Variabilní Panel menu Nabídka panelu Unembed… Zrušit vložení… Applies the next time VectorCraft starts. Projeví se při příštím spuštění VectorCraftu. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dokud je proměnná prostředí VECTORCRAFT_TITLEBAR nastavena, záhlaví okna volí ona. Enable Contextual Task Bar Povolit kontextový panel úloh Next Další OK OK diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 994f115ab..b0bdbb99f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3633,6 +3633,7 @@ font-technology Variable Variable Panel menu Panelmenu Unembed… Ophæv integrering… Applies the next time VectorCraft starts. Anvendes næste gang VectorCraft starter. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet. Enable Contextual Task Bar Aktivér kontekstuel opgavelinje Next Næste OK OK diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a3534cce1..88ede5921 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -179,6 +179,7 @@ font-technology Variable Variabel Appearance panel Bedienfeld „Aussehen“ Applies when the linked file is relinked or updated. Gilt, wenn die verknüpfte Datei neu verknüpft oder aktualisiert wird. Applies the next time VectorCraft starts. Gilt ab dem nächsten Start von VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste. Apply Anwenden Apply Brush Pinsel anwenden Apply Character Style Zeichenformat anwenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f537725c0..aa1bb7452 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -183,6 +183,7 @@ font-technology Variable Μεταβλητές Appearance Panel Πίνακας εμφάνισης Appearance panel Πίνακας εμφάνισης Applies the next time VectorCraft starts. Εφαρμόζεται στην επόμενη εκκίνηση του VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. Apply Εφαρμογή Apply Brush Εφαρμογή πινέλου Apply Character Style Εφαρμογή στυλ χαρακτήρων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index da1f4b362..86bab2cf7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -204,6 +204,7 @@ font-technology Variable Variables Appearance panel Panel Apariencia Applies when the linked file is relinked or updated. Se aplica al volver a vincular o actualizar el archivo vinculado. Applies the next time VectorCraft starts. Se aplica la próxima vez que se inicie VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título. Apply Aplicar Apply Brush Aplicar pincel Apply Character Style Aplicar estilo de carácter diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ebb354f9..b3b5eac8f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3814,6 +3814,7 @@ font-technology Variable Muuttuvat Panel menu Paneelivalikko Unembed… Poista upotus… Applies the next time VectorCraft starts. Tulee voimaan, kun VectorCraft käynnistetään seuraavan kerran. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu. Enable Contextual Task Bar Ota kontekstuaalinen tehtäväpalkki käyttöön Next Seuraava OK OK diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 943ca1140..9e9d6f113 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -205,6 +205,7 @@ font-technology Variable Variables Appearance panel Panneau Aspect Applies when the linked file is relinked or updated. S’applique lorsque le fichier lié est de nouveau lié ou mis à jour. Applies the next time VectorCraft starts. S’applique au prochain démarrage de VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Tant qu’elle est définie, la variable d’environnement VECTORCRAFT_TITLEBAR choisit la barre de titre. Apply Appliquer Apply Brush Appliquer la forme Apply Character Style Appliquer le style de caractère diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 670c93a99..7c8050626 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -286,6 +286,7 @@ font-technology Variable Változó Appearance panel Megjelenés panel Appearance Panel Megjelenés panel Applies the next time VectorCraft starts. A VectorCraft következő indításakor lép életbe. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort. Applies when the linked file is relinked or updated. A csatolt fájl újracsatolásakor vagy frissítésekor érvényes. Apply Alkalmaz Apply Brush Ecset alkalmazása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c91631178..cb01172bd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3633,6 +3633,7 @@ font-technology Variable Variabel Panel menu Menu panel Unembed… Lepaskan sematan… Applies the next time VectorCraft starts. Berlaku saat VectorCraft dimulai berikutnya. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul. Enable Contextual Task Bar Aktifkan bilah tugas kontekstual Next Berikutnya OK OK diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf329b283..8fca3e13e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -204,6 +204,7 @@ font-technology Variable Variabili Appearance panel Pannello Aspetto Applies when the linked file is relinked or updated. Si applica quando il file collegato viene ricollegato o aggiornato. Applies the next time VectorCraft starts. Si applica al prossimo avvio di VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo. Apply Applica Apply Brush Applica pennello Apply Character Style Applica stile carattere diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7449d1f09..6c586d2eb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -920,6 +920,7 @@ font-technology Variable バリアブル Appearance of Black ブラックのアピアランス Appearance panel アピアランスパネル Applies the next time VectorCraft starts. VectorCraft の次回起動時に適用されます。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。 Applies when the linked file is relinked or updated. リンクファイルを再リンクまたは更新したときに適用されます。 Apply Brush ブラシを適用 Apply Character Style 文字スタイルを適用 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index b22d3d09a..19e939a95 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3412,6 +3412,7 @@ font-technology Variable ცვლადი Panel menu პანელის მენიუ Unembed… ჩაშენების მოხსნა… Applies the next time VectorCraft starts. ძალაში შევა VectorCraft-ის მომდევნო გაშვებისას. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს. Enable Contextual Task Bar კონტექსტური ამოცანების ზოლის ჩართვა Next შემდეგი OK OK diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6d64b057b..78d691dfe 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -915,6 +915,7 @@ font-technology Variable 가변 Appearance of Black 검정 표시 방식 Appearance panel 모양새 패널 Applies the next time VectorCraft starts. 다음에 VectorCraft를 시작할 때 적용됩니다. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 환경 변수 VECTORCRAFT_TITLEBAR가 설정되어 있는 동안에는 이 변수가 제목 표시줄을 정합니다. Applies when the linked file is relinked or updated. 링크된 파일을 다시 연결하거나 업데이트할 때 적용됩니다. Apply Brush 브러시 적용 Apply Character Style 문자 스타일 적용 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b0e9ed9b8..52ad92ce3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3647,6 +3647,7 @@ font-technology Variable Variabel Panel menu Paneelmenu Unembed… Insluiten ongedaan maken… Applies the next time VectorCraft starts. Wordt toegepast de volgende keer dat VectorCraft start. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk. Enable Contextual Task Bar Contextuele taakbalk inschakelen Next Volgende OK OK diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8a52c4c51..b82d72bbd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3729,6 +3729,7 @@ font-technology Variable Zmienne Panel menu Menu panelu Unembed… Wyodrębnij… Applies the next time VectorCraft starts. Zostanie zastosowane przy następnym uruchomieniu VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu. Enable Contextual Task Bar Włącz kontekstowy pasek zadań Next Dalej OK OK diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3f3bdccb2..42b97e938 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -199,6 +199,7 @@ Appearance of Black Aparência do Preto Appearance panel Painel de Aparência Applies the next time VectorCraft starts. Aplica na próxima inicialização do VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título. Applies when the linked file is relinked or updated. Aplica quando o arquivo vinculado é revinculado ou atualizado. Apply Brush Aplicar Pincel Apply Character Style Aplicar Estilo de Caractere diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 59f8cd4f0..85ae7ece8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -202,6 +202,7 @@ font-technology Variable Вариативные Appearance panel Панель «Оформление» Applies when the linked file is relinked or updated. Применяется при повторной привязке или обновлении связанного файла. Applies the next time VectorCraft starts. Применяется при следующем запуске VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она. Apply Применить Apply Brush Применить кисть Apply Character Style Применить стиль символа diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c46f2970f..1e1700e43 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -192,6 +192,7 @@ font-technology Variable Variabla Appearance panel Utseendepanelen Applies when the linked file is relinked or updated. Gäller när den länkade filen länkas om eller uppdateras. Applies the next time VectorCraft starts. Gäller nästa gång VectorCraft startas. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven. Apply Använd Apply Brush Använd pensel Apply Character Style Använd teckenstil diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ce6b2229e..31d4a4e67 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -189,6 +189,7 @@ font-technology Variable Değişken Appearance panel Görünüm paneli Applies when the linked file is relinked or updated. Bağlantılı dosya yeniden bağlandığında veya güncellendiğinde geçerlidir. Applies the next time VectorCraft starts. VectorCraft'ın bir sonraki başlatılışında uygulanır. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer. Apply Uygula Apply Brush Fırça Uygula Apply Character Style Karakter Stilini Uygula diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 65384df71..2ab613e4c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -198,6 +198,7 @@ font-technology Variable Варіативні Appearance panel Панель «Оформлення» Applies when the linked file is relinked or updated. Використовується при повторній прив’язці або оновленні пов’язаного файлу. Applies the next time VectorCraft starts. Застосовується при наступному запуску VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона. Apply Застосувати Apply Brush Застосувати пензель Apply Character Style Застосувати стиль символу diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9ab99612..82ce7e7f3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -221,6 +221,7 @@ font-technology Variable Biến thiên Appearance Panel Bảng Diện mạo Appearance panel Bảng diện mạo Applies the next time VectorCraft starts. Áp dụng vào lần tiếp theo VectorCraft khởi động. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề. Applies when the linked file is relinked or updated. Áp dụng khi tệp liên kết được liên kết lại hoặc cập nhật. Apply Áp dụng Apply Brush Áp dụng cọ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0daa3138f..66b79c6a4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4663,6 +4663,7 @@ imageTrace Outline 轮廓 Font Technology 字体技术 font-technology Variable 可变 Applies the next time VectorCraft starts. 下次启动 VectorCraft 时生效。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。 Collapse to Icons 折叠为图标 Decorative 装饰 Dim Images to: 图像变暗至: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 427c20da2..1a11d526d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -162,6 +162,7 @@ font-technology Variable 可變 Appearance panel 外觀面板 Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。 Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。 + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 設定了環境變數 VECTORCRAFT_TITLEBAR 時,由它選擇標題列。 Apply 套用 Apply Brush 套用筆刷 Apply Character Style 套用字元樣式 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4efc27211..c3c55d6ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -521,6 +521,9 @@ pub struct VectorcraftApp { /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, + /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User + /// Interface › System Title Bar does not apply while it is set. `false` unless the host sets it. + pub titlebar_from_env: bool, /// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`): /// sent again only when it changes, so idle frames don't spam the backend. pub(crate) last_window_title: String, @@ -813,6 +816,7 @@ impl VectorcraftApp { canvas_rect: None, hover_doc: None, custom_titlebar: false, + titlebar_from_env: false, last_window_title: String::new(), graphics_adapter: None, place: Default::default(), diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 9057aeb13..afe1af349 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -32,6 +32,8 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's note). + fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); } @@ -278,9 +280,16 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { // Windows and Linux only: macOS always has the system's title bar, the web has none. PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - bool_row(ui, d, sp.key, sp.label); - // The window's decorations are chosen once, when the app starts. - ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0)); + // The window's decorations are chosen once, when the app starts; while + // VECTORCRAFT_TITLEBAR is set it chooses them, and the checkbox is dimmed. + let from_env = d.bool("__titlebarFromEnv"); + ui.add_enabled_ui(!from_env, |ui| bool_row(ui, d, sp.key, sp.label)); + let note = if from_env { + tl!("The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set.") + } else { + tl!("Applies the next time VectorCraft starts.") + }; + ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), @@ -646,6 +655,61 @@ mod tests { assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); } + /// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar says so in place of the + /// next-launch note, and its checkbox does not take clicks. + #[test] + fn system_title_bar_names_the_environment_variable_that_overrides_it() { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let note = "The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set."; + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + for from_env in [false, true] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); + assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported && !from_env, "{shown:?}"); + // A click on the checkbox's label: it turns the preference on only without the variable. + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()), + _ => None, + }); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { + pos: r.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}"); + } + assert_eq!(label.is_some(), supported); + } + } + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7bfbcfe16..3735659e9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -310,7 +310,11 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar -drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. +drops its brand mark. On Linux and the BSDs the environment variable `VECTORCRAFT_TITLEBAR` chooses the frame for one +run, whatever the preference says: `app` keeps the app bar as the title bar, and `system` or `desktop` gives the +window the desktop's decorations. While it is set, the Preferences dialog dims System Title Bar and its +`__titlebarFromEnv` field is `true`. On macOS the window buttons are centred on the app bar; the web build is +unchanged. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); diff --git a/docs/development.md b/docs/development.md index 74dc55a30..ac458332a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,7 +109,7 @@ On macOS the menus live in the system menu bar, not in the window's app bar (whi ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. +On every Linux desktop the app bar is the window's title bar by default. Enable **Preferences › User Interface › System Title Bar** and restart to give the window the desktop's frame instead: KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers draw it, and on GNOME/Wayland winit draws its font-free Adwaita window controls (`wayland-csd-adwaita-notitle`). `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run, whatever the preference says. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4f5dbcba1..c2fa2912c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -610,6 +610,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new document with no New Document dialog box. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). + - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` or `=system` chooses the frame for one run. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** - **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain. From eb65e64882712989e72dea9b5348935696350a3e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 01:07:48 -0700 Subject: [PATCH 02/23] Docs: name VECTORCRAFT_TITLEBAR=desktop and qualify the Linux app bar line --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 09f540fa4..93492a652 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -610,8 +610,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new document with no New Document dialog box. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` or `=system` chooses the frame for one run. - - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. + - On Linux the app bar is the title bar on every desktop, KDE Plasma, Sway, Hyprland, COSMIC and X11 window managers included, unless Preferences › User Interface › System Title Bar gives the window the desktop's frame; `VECTORCRAFT_TITLEBAR=app` chooses VectorCraft's bar and `=system` or `=desktop` (the two are the same) chooses the desktop's frame for one run. + - On Windows, and on Linux unless the desktop's frame is chosen, the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). - On Windows a maximized window stops one pixel short of each monitor edge where an auto-hidden taskbar sits. - **Tools:** From 8b63892265337fec6a98f99754bfe4d36afa4008 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 01:37:29 -0700 Subject: [PATCH 03/23] Linux: drop the xwayland import that the main merge left unused in titlebar.rs --- apps/vectorcraft/src/titlebar.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 5ff29657f..280965456 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -1,6 +1,4 @@ use super::window; -#[cfg(target_os = "linux")] -use super::xwayland; /// 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 From 547b0aa2dd3d34780ff3fd9fc9b362992fce7e5c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:48:37 -0700 Subject: [PATCH 04/23] i18n: Catalan row for the VECTORCRAFT_TITLEBAR note in Preferences --- crates/ui-egui/src/i18n/ca.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4022efa29..da234a9c9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -303,6 +303,7 @@ Appearance panel Panell Aparença Applied Filters Filtres aplicats Applies the next time VectorCraft starts. S’aplica la propera vegada que s’iniciï VectorCraft. + The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol. Applies when the linked file is relinked or updated. S’aplica quan el fitxer enllaçat es torna a enllaçar o s’actualitza. Apply Aplica Apply Brush Aplica el pinzell From a1c908fbd20f5ec11f083d7649678c1d3078becf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 00:06:04 -0700 Subject: [PATCH 05/23] Linux: test VECTORCRAFT_TITLEBAR read from the environment with System Title Bar off and on Runs the test binary again with VECTORCRAFT_TITLEBAR set to app, system, desktop and an unknown value, and checks titlebar_override, custom_titlebar with no saved preferences and with System Title Bar off and on, and the window decorations native_options sets before the window opens. On macOS and Windows the variable is not read. --- apps/vectorcraft/src/tests.rs | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index aead748a9..1cc5ba1e9 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -171,6 +171,45 @@ fn every_linux_desktop_gets_the_app_title_bar_unless_system_title_bar_is_on() { } } +/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on. +/// The variable is process-wide and read once, so each value runs in a separate test process. +#[test] +fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { + const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; + if std::env::var_os(CHILD).is_none() { + for value in ["app", "system", "desktop", "unknown"] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) + .env(CHILD, "1") + .env("VECTORCRAFT_TITLEBAR", value) + .output() + .unwrap(); + assert!( + output.status.success(), + "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + } + let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap(); + let expected = match value.as_str() { + _ if cfg!(any(target_os = "macos", target_os = "windows")) => None, + "app" => Some(true), + "system" | "desktop" => Some(false), + _ => None, + }; + assert_eq!(super::titlebar_override(), expected, "VECTORCRAFT_TITLEBAR={value}"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { + let custom = super::custom_titlebar(saved); + assert_eq!(custom, super::custom_titlebar_from(expected, opted_into_system), "{value}, preference {opted_into_system}"); + assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] From 5e1a50c75ae5ffe93187ce643248a9013a16ab88 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 01:52:37 -0700 Subject: [PATCH 06/23] Linux: hold spawning while the title bar test starts its child process On Linux and the BSDs, the_titlebar_variable_decides_the_frame_before_the_window_opens now holds spawning() while it starts each child test process, as the portal save test does around dbus-daemon, so the child does not keep a copy of a GPU start test's locked start file. --- apps/vectorcraft/src/tests.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 1cc5ba1e9..e6ebaedcf 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -178,12 +178,21 @@ fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; if std::env::var_os(CHILD).is_none() { for value in ["app", "system", "desktop", "unknown"] { - let output = std::process::Command::new(std::env::current_exe().unwrap()) + // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a + // copy of a GPU start test's locked start file (see `CHILD_SPAWN`). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + let spawning = super::spawning(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) .env(CHILD, "1") .env("VECTORCRAFT_TITLEBAR", value) - .output() - .unwrap(); + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + drop(spawning); + let output = child.unwrap().wait_with_output().unwrap(); assert!( output.status.success(), "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", From c629753af1c80ecca19a189bbb37eeaa2e009154 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 17:14:27 -0800 Subject: [PATCH 07/23] Plug-ins: settings and the cache total stay in memory between storage calls A settings get no longer reads and parses settings.json each time. The parsed settings are kept per plug-in folder and read again only when the file's size or modified time changes. A set to the value a key already has writes nothing, and every other change is written with an atomic write before the call returns. plugin.remove drops the kept copy. cacheBytes in plugin.list, plugin.info and plugin.install comes from a total kept while the cache folder keeps its modified time, instead of a listing of up to 8,192 items per call. A cache write still lists the folder to enforce the caps. docs/plugins.md states what a run counts and that a cache call moves a whole entry in one step. docs/plugins.md and docs/mcp.md now say when a later plugin.remove retries a leftover .removed folder. --- crates/engine/src/store.rs | 161 +++++++++++++++++++++++------ crates/engine/src/tests/plugins.rs | 138 +++++++++++++++++++++++-- crates/plugins/src/host.rs | 2 +- crates/plugins/src/storage.rs | 5 +- docs/mcp.md | 4 +- docs/plugins.md | 18 +++- docs/target-app-parity.md | 1 + 7 files changed, 280 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 27b9ad8ac..1836c7bd6 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -32,7 +32,8 @@ use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; use vectorcraft_plugins::safe_mode; @@ -79,9 +80,9 @@ pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2; /// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose /// each other's settings. static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); -/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder -/// until the plug-in is removed, which sets it and drops it. -static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The state of each plug-in folder's storage, shared by every storage made for the folder until +/// the plug-in is removed, which closes it and drops it. +static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -474,7 +475,7 @@ impl Session { /// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it. /// The folder is first renamed out of the way (`.removed`), and that fails, leaving it /// as it was, when the rename does; what the deletion then leaves stays under that name, with a - /// log record, and is deleted at the plug-in's next removal. + /// log record. A later call for the id tries to delete it again. pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> { vectorcraft_plugins::registry::set_storage(id, None); let Some(dir) = &self.plugin_dir else { return Ok(()) }; @@ -483,8 +484,8 @@ impl Session { // Every storage handed out for the folder, including those runs still hold, is closed once // the folder is out of the way; until then they keep working. let close = || { - if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { - closed.store(true, Ordering::Release); + if let Some(data) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) { + data.closed.store(true, Ordering::Release); } }; // A `storage` folder that is a link leads out of the store: nothing there is deleted. A link @@ -543,22 +544,67 @@ pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf { fn plugin_storage(dir: Option<&Path>, id: &str) -> Option> { let dir = dir?; let data = plugin_data_dir(dir, id); - let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); - Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed })) + let state = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone(); + Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, state })) +} + +/// A file's size and modified time (`None`: there is no file). +type FileStamp = Option<(u64, SystemTime)>; + +/// The stamp of the file at `path`. +fn file_stamp(path: &Path) -> Result { + match std::fs::metadata(path) { + Ok(m) => Ok(Some((m.len(), m.modified().map_err(|e| io(&e))?))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io(&e)), + } +} + +/// The modified time of the folder at `dir` (`None`: it can't be read, or there is no folder). +fn folder_stamp(dir: &Path) -> Option { + std::fs::symlink_metadata(dir).ok()?.modified().ok() +} + +/// What every storage made for one plug-in folder shares ([`PLUGIN_DATA_OPEN`]). +#[derive(Default)] +struct PluginData { + /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed. + closed: AtomicBool, + kept: Mutex, +} + +/// What the folder's files last held in this process. +#[derive(Default)] +struct Kept { + settings: Option, + cache: Option, +} + +/// The settings by key, and the settings file's stamp when they were read or written. +struct KeptSettings { + stamp: FileStamp, + values: BTreeMap>, +} + +/// The bytes of the cache entries, and the cache folder's stamp when they were added up. +#[derive(Clone, Copy)] +struct KeptCache { + stamp: Option, + bytes: u64, } /// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs). /// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as /// [`case_safe`] writes it, so that names differing only in case get different files. A link /// anywhere in the folder's path below the store is refused, and so is a cache file that is not a -/// plain file. +/// plain file. The settings stay in memory ([`Kept`]) while the settings file keeps its size and +/// modified time, and every change is written to the file before the call returns. The total +/// `cache_bytes` reports stays in memory while the cache folder keeps its modified time. pub(crate) struct PluginStorage { /// The plug-in store, and the plug-in's folder in it. root: PathBuf, dir: PathBuf, - /// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the - /// folder ([`PLUGIN_DATA_OPEN`]). - closed: Arc, + state: Arc, } /// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system. @@ -631,17 +677,29 @@ impl PluginStorage { Ok(()) } - /// The settings, by key. A file that is too large or doesn't parse is moved aside - /// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the - /// file's size before it is read. - fn read_settings(&self, meter: &mut Meter) -> Result>, StorageError> { + fn kept(&self) -> MutexGuard<'_, Kept> { + self.state.kept.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// The settings: those in `kept` when the settings file still has their stamp, else the file's, + /// which then replace them. + fn read_settings<'a>(&self, kept: &'a mut Option, meter: &mut Meter) -> Result<&'a mut KeptSettings, StorageError> { let path = self.settings_path(); self.no_links(&path)?; - let size = match std::fs::metadata(&path) { - Ok(m) => m.len(), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), - Err(e) => return Err(io(&e)), + let stamp = file_stamp(&path)?; + let held = match kept.take() { + Some(held) if held.stamp == stamp => held, + _ => self.load_settings(stamp, meter)?, }; + Ok(kept.insert(held)) + } + + /// Reads the settings file, whose stamp is `stamp`. A file that is too large or doesn't parse is + /// moved aside ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is + /// charged the file's size before it is read. + fn load_settings(&self, stamp: FileStamp, meter: &mut Meter) -> Result { + let path = self.settings_path(); + let Some((size, _)) = stamp else { return Ok(KeptSettings { stamp, values: BTreeMap::new() }) }; let readable = size <= storage::MAX_SETTINGS_BYTES as u64; if readable { meter.take(size)?; @@ -657,12 +715,30 @@ impl PluginStorage { settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES)) }); match parsed { - Some(settings) => Ok(settings), + Some(values) => Ok(KeptSettings { stamp, values }), None => { let aside = self.dir.join(BAD_SETTINGS_FILE); log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display()); std::fs::rename(&path, &aside).map_err(|e| io(&e))?; - Ok(BTreeMap::new()) + Ok(KeptSettings { stamp: None, values: BTreeMap::new() }) + } + } + } + + /// Writes the settings in `kept` to the file and records the file's new stamp. When that fails, + /// or settings were written and the file is gone, `kept` is emptied, so the next call reads the + /// file again. + fn save_settings(&self, kept: &mut Option, meter: &mut Meter) -> Result<(), StorageError> { + let Some(held) = kept.as_mut() else { return Ok(()) }; + let written = self.write_settings(&held.values, meter); + match (written, file_stamp(&self.settings_path())) { + (Ok(()), Ok(stamp)) if stamp.is_some() || held.values.is_empty() => { + held.stamp = stamp; + Ok(()) + } + (written, _) => { + *kept = None; + written } } } @@ -690,7 +766,7 @@ impl PluginStorage { /// Fails with [`StorageError::Unavailable`] once the storage is closed. fn open(&self) -> Result<(), StorageError> { - if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } + if self.state.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) } } /// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which @@ -727,28 +803,33 @@ impl Storage for PluginStorage { fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - Ok(self.read_settings(meter)?.remove(key)) + let mut kept = self.kept(); + Ok(self.read_settings(&mut kept.settings, meter)?.values.get(key).cloned()) } fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; + let mut kept = self.kept(); + let settings = &mut self.read_settings(&mut kept.settings, meter)?.values; if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) { return Err(StorageError::Full); } + if settings.get(key).is_some_and(|old| old == value) { + return Ok(()); + } settings.insert(key.to_string(), value.to_vec()); - self.write_settings(&settings, meter) + self.save_settings(&mut kept.settings, meter) } fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result { let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner()); self.open()?; - let mut settings = self.read_settings(meter)?; - if settings.remove(key).is_none() { + let mut kept = self.kept(); + if self.read_settings(&mut kept.settings, meter)?.values.remove(key).is_none() { return Ok(false); } - self.write_settings(&settings, meter).map(|()| true) + self.save_settings(&mut kept.settings, meter).map(|()| true) } fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result>, StorageError> { @@ -782,7 +863,10 @@ impl Storage for PluginStorage { meter.take(value.len() as u64)?; std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?; self.no_links(&path)?; - write_atomic(&path, value).map_err(|e| io(&e)) + write_atomic(&path, value).map_err(|e| io(&e))?; + let stamp = folder_stamp(&self.cache_dir()); + self.kept().cache = stamp.map(|_| KeptCache { stamp, bytes: total }); + Ok(()) } fn remove_cache_entry(&self, name: &str) -> Result { @@ -791,7 +875,10 @@ impl Storage for PluginStorage { let path = self.entry_path(name); self.no_links(&path)?; match std::fs::remove_file(&path) { - Ok(()) => Ok(true), + Ok(()) => { + self.kept().cache = None; + Ok(true) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(io(&e)), } @@ -819,6 +906,7 @@ impl Storage for PluginStorage { freed = freed.saturating_add(m.len()); } } + self.kept().cache = Some(KeptCache { stamp: folder_stamp(&dir), bytes: 0 }); Ok(freed) } @@ -826,7 +914,14 @@ impl Storage for PluginStorage { if self.open().is_err() { return 0; } - self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0) + let stamp = folder_stamp(&self.cache_dir()); + if let Some(kept) = self.kept().cache.filter(|k| k.stamp == stamp) { + return kept.bytes; + } + let Ok((files, _)) = self.cache_files() else { return 0 }; + let bytes = sum_sizes(&files); + self.kept().cache = Some(KeptCache { stamp, bytes }); + bytes } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 5d947b900..f9472e545 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1548,9 +1548,14 @@ fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Stora std::fs::remove_file(&entry_copy).unwrap(); } +/// Gives the file at `path` the modified time `when`, whatever the file system's clock says. +fn set_modified(path: &std::path::Path, when: std::time::SystemTime) { + std::fs::File::options().write(true).open(path).unwrap().set_modified(when).unwrap(); +} + /// Each storage call is charged the bytes it reads and writes, before it does: a settings call the -/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or -/// writes. A call that would pass what its meter has left fails and changes nothing. +/// whole settings file when it reads the file and the whole file when it writes it, a cache call the +/// entry it reads or writes. A call that would pass what its meter has left fails and changes nothing. fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { use vectorcraft_plugins::storage::{Meter, StorageError}; let folder = crate::tests_links::Folder::new("plugin-storage-meter"); @@ -1564,22 +1569,25 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap(); st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap(); - // Reading a one-byte setting reads the whole file. + // A file another window changed is read whole, even for a one-byte setting. + let touch = || set_modified(&file, std::fs::metadata(&file).unwrap().modified().unwrap() + std::time::Duration::from_secs(1)); + touch(); let before = size(); let mut m = Meter::unlimited(); assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec())); assert_eq!(m.used(), before); - // Setting it reads the file and writes it again, and so does deleting one. + // Setting it writes the file again, and so does deleting one. let mut m = Meter::unlimited(); st.set_setting("small", b"yz", &mut m).unwrap(); - assert_eq!(m.used(), before + size()); - let (before, mut m) = (size(), Meter::unlimited()); + assert_eq!(m.used(), size()); + let mut m = Meter::unlimited(); assert!(st.delete_setting("small", &mut m).unwrap()); - assert_eq!(m.used(), before + size()); + assert_eq!(m.used(), size()); st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap(); // Less left than the file: nothing is read. Enough to read it but not to write it again: // nothing changes. + touch(); let mut m = Meter::new(size() - 1); assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget)); assert_eq!(m.used(), 0); @@ -1599,6 +1607,122 @@ fn plugin_storage_calls_are_charged_the_files_they_read_and_write() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// A session with the plug-in `id` installed in a new store → its storage and the path of its +/// settings file. +fn storage_in( + s: &mut Session, + folder: &crate::tests_links::Folder, + id: &str, +) -> (std::sync::Arc, std::path::PathBuf) { + s.attach_plugin_store(folder.0.clone()); + install_storing(s, id, ""); + (vectorcraft_plugins::registry::storage(id).unwrap(), crate::store::plugin_data_dir(&folder.0, id).join("settings.json")) +} + +/// The settings stay in memory while the file keeps its size and modified time: a read doesn't open +/// the file, a set to the value already there doesn't write it, a real change is in the file when +/// the call returns, and a file with another time is read again. +#[test] +fn plugin_settings_are_read_from_memory_until_the_file_changes() { + use vectorcraft_plugins::storage::{Meter, StorageError}; + let folder = crate::tests_links::Folder::new("plugin-storage-kept"); + let id = "test.engine.storagekept"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let written = std::fs::metadata(&file).unwrap().modified().unwrap(); + + // Other bytes of the same size and the same time: the storage can't tell, so what it answers + // shows that it did not read the file. + let other = + std::fs::read_to_string(&file).unwrap().replace(&vectorcraft_format::base64_encode(b"hello"), &vectorcraft_format::base64_encode(b"world")); + std::fs::write(&file, &other).unwrap(); + set_modified(&file, written); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), 0, "answered from memory"); + + // Setting the value it has writes nothing: the file still holds the other bytes. + let mut m = Meter::unlimited(); + st.set_setting("zoom", b"hello", &mut m).unwrap(); + assert_eq!(m.used(), 0, "nothing to write"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), other); + + // Another time: read again. + set_modified(&file, written + std::time::Duration::from_secs(1)); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"world".to_vec())); + assert_eq!(m.used(), other.len() as u64); + + // A change reaches the file before the call returns, and a failed one is not kept. + st.set_setting("tile", b"x", &mut Meter::unlimited()).unwrap(); + assert!(std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + assert_eq!(st.set_setting("tile", b"y", &mut Meter::new(0)), Err(StorageError::Budget)); + assert_eq!(st.setting("tile", &mut Meter::unlimited()).unwrap(), Some(b"x".to_vec())); + assert!(st.delete_setting("tile", &mut Meter::unlimited()).unwrap()); + assert!(!std::fs::read_to_string(&file).unwrap().contains("\"tile\"")); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// `plugin.remove` drops what was kept: a file put back with the removed settings' size and time is +/// read from disk by the storage of the id installed again. +#[test] +fn removing_a_plugin_drops_its_settings_from_memory() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-removed"); + let id = "test.engine.storagekeptremoved"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + st.set_setting("zoom", b"hello", &mut Meter::unlimited()).unwrap(); + let (bytes, written) = (std::fs::read(&file).unwrap(), std::fs::metadata(&file).unwrap().modified().unwrap()); + run(&mut s, "plugin.remove", json!({"id": id})); + + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, &bytes).unwrap(); + set_modified(&file, written); + s.attach_plugin_storage(id); + let st = vectorcraft_plugins::registry::storage(id).unwrap(); + let mut m = Meter::unlimited(); + assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec())); + assert_eq!(m.used(), bytes.len() as u64, "read from the file"); + s.remove_plugin_storage(id).unwrap(); +} + +/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear +/// update it, and removing an entry drops it. A file whose length changes behind the storage leaves +/// the folder's time alone, so the total it reports shows that it did not list the folder. +#[test] +fn plugin_cache_total_is_kept_and_follows_writes_removals_and_clears() { + use vectorcraft_plugins::storage::Meter; + let folder = crate::tests_links::Folder::new("plugin-storage-kept-cache"); + let id = "test.engine.storagekeptcache"; + let mut s = session(); + let (st, file) = storage_in(&mut s, &folder, id); + let cache = file.parent().unwrap().join("cache"); + let m = &mut Meter::unlimited(); + let grow = |name: &str, len: u64| std::fs::File::options().write(true).open(cache.join(name)).unwrap().set_len(len).unwrap(); + + st.set_cache_entry("tile", b"abc", m).unwrap(); + grow("tile.bin", 10); + assert_eq!(st.cache_bytes(), 3, "the write set the total"); + // A write lists the folder for its caps, and starts from what it finds. + st.set_cache_entry("more", b"12345", m).unwrap(); + grow("more.bin", 5); + assert_eq!(st.cache_bytes(), 15); + assert!(st.remove_cache_entry("more").unwrap()); + assert_eq!(st.cache_bytes(), 10, "after a removal the total comes from the folder"); + grow("tile.bin", 20); + assert_eq!(st.cache_bytes(), 10, "and is kept again"); + assert_eq!(st.clear_cache().unwrap(), 20); + assert_eq!(st.cache_bytes(), 0); + + // A folder deleted behind the storage is noticed. + st.set_cache_entry("tile", b"abc", m).unwrap(); + std::fs::remove_dir_all(&cache).unwrap(); + assert_eq!(st.cache_bytes(), 0); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A module with other bytes than the plug-in installed with its id, or, with none installed, one /// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and /// then keeps them; the same module installs again without it, and `replace` must be true or false. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs index 017df5a80..f8235a4fb 100644 --- a/crates/plugins/src/host.rs +++ b/crates/plugins/src/host.rs @@ -91,7 +91,7 @@ pub const STORAGE_BUDGET: i32 = -7; pub const MAX_STORAGE_CALLS: usize = 10_000; /// Most bytes one run's storage calls read and write, all together, counting the whole settings -/// file each settings call reads and writes and each cache entry read or written; a call that would +/// file each time it is read or written and each cache entry read or written; a call that would /// pass it returns [`STORAGE_BUDGET`]. pub const MAX_STORAGE_BYTES: usize = 1 << 30; diff --git a/crates/plugins/src/storage.rs b/crates/plugins/src/storage.rs index f3ef2e899..b7de23c45 100644 --- a/crates/plugins/src/storage.rs +++ b/crates/plugins/src/storage.rs @@ -93,8 +93,9 @@ impl std::fmt::Display for StorageError { /// Where one plug-in's settings and cache are kept. Every key and name the host passes is a /// [`valid_name`], and every value is within its cap ([`MAX_SETTING_BYTES`], [`MAX_CACHE_ENTRY`]); /// the storage enforces the totals. The calls that read or write a file charge `meter` the bytes -/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file it -/// reads and the whole file it writes, a cache call the entry it reads or writes. +/// they read and write ([`Meter::take`]) before they do: a settings call the whole settings file +/// when it reads the file and the whole file when it writes it, a cache call the entry it reads or +/// writes. A settings call that the storage answers from memory charges nothing. pub trait Storage: Send + Sync { /// The value of setting `key`, if it has one. fn setting(&self, key: &str, meter: &mut Meter) -> Result>, StorageError>; diff --git a/docs/mcp.md b/docs/mcp.md index f2b9cd0f3..d296c34db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6578,7 +6578,9 @@ under its id. One that lists `settings` or `cache` keeps settings or a cache of size as `cacheBytes`, `plugin.clearCache {id?}` empties the cache of one plug-in or of all of them and returns `{cleared: [id], bytesFreed}`, replacing a plug-in keeps both, and `plugin.remove` deletes both (also for an id that isn't installed but still has them). Files it can't delete stay in the plug-in's folder renamed with `.removed`, where -no plug-in reaches them, until a plug-in with that id is removed again ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none +no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in with that id is installed or +a storage folder for the id exists, and that deletion can fail too; for an id with only a `.removed` folder it does +not ([plugins.md](plugins.md#settings-and-cache)). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has settings or a cache in the `Plug-ins` folder (`needsReplace: true` in its `plugin.inspect` entry), installs only with `replace: true`: ask the user first, since it takes that plug-in's place and gets its settings and cache. Without it, `plugin.install` returns diff --git a/docs/plugins.md b/docs/plugins.md index 94d68697b..d7eb95694 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -269,10 +269,16 @@ the key as `(key, key_len)`, the UTF-8 bytes at `key` in its memory: A call that fails returns `-1` when the key has no value, `-2` when a key, value or buffer doesn't lie inside the module's memory, `-3` when the key is not a valid key, `-4` when the write would pass a cap (nothing changes), `-5` when there is nowhere to keep anything, `-6` when reading or writing the file failed, and `-7` when the run has used -its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes: a -settings call the whole settings file it reads, and a set or delete also the whole file it writes; a cache call the -entry it reads or writes. A read counts them even when the value doesn't fit and nothing is copied. A call that would -pass the 1 GiB returns `-7` before it reads or writes anything. +its 10,000 storage calls or its 1 GiB of storage reads and writes. Each call counts the bytes it reads and writes. A +settings call counts the whole settings file when it reads the file, and a set or delete also counts the whole file +when it writes the file; a cache call counts the entry it reads or writes. A read counts them even when the value +doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` before it reads or writes anything. + +The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size +or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A +set to the value the key already has writes nothing. Every other set or delete writes the file before the call +returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and the run waits for it. +A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9`, `LPT0` to `LPT9`, in any case). Keys differ in case: `tile` and `Tile` are two. @@ -291,7 +297,9 @@ pass the 1 GiB returns `-7` before it reads or writes anything. (`plugin.install` with `replace: true`, after the user agreed) keeps the storage; `plugin.remove` deletes it, and a run of the plug-in still going gets `-5` from every storage call after that. `plugin.remove` first renames the folder (adding `.removed` to its name). When that fails, `plugin.remove` fails and the plug-in stays installed with its storage as it was. Files that can't be deleted after the rename stay in the - `.removed` folder, where no plug-in reaches them, and the next removal of a plug-in with that id deletes them. + `.removed` folder, where no plug-in reaches them. A later `plugin.remove` tries again to delete them when a plug-in + with that id is installed or a storage folder for the id exists, and that deletion can fail too. For an id that + isn't installed and has only a `.removed` folder, `plugin.remove` does not delete the folder and fails as not found. `plugin.remove` with the id of a plug-in that isn't installed deletes the storage kept for that id, when there is any. `plugin.clearCache` empties the cache and leaves the settings, and `plugin.list` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8ee5cbe92..d2de3d9e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1565,6 +1565,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From b1236c469b0915852edddadbc52e421d533ad413 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 12:09:36 -0700 Subject: [PATCH 08/23] Print: Copies per Sheet prints as many copies of each artboard as fit the sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File › Print printed each artboard on a sheet of its own. A 2 × 3.5 in card on Letter printed once in the middle of the page, and Print had no option to fill the sheet with copies. The Print dialog's General section has Copies per Sheet (off by default) and a Gutter. With it on, a sheet holds as many copies of the artboard as fit the imageable area, each with its bleed and marks, across then down, gutter pt apart. The card prints 4 × 3 on Letter, and 3 × 3 with an 18 pt gutter. The copies are not turned, and the placement places the whole grid as it places one copy. An artboard that does not fit prints one copy per sheet with a warning, and more than 1,000 copies on a sheet is an error. Tiling leaves the option out. The PDF and PostScript writers draw the art and the marks once per copy, the PDF TrimBox holds every copy, the preview draws each copy, and print.preview lists them as `copies`. With the option off, the layout lists no copies and the writers draw the page as before. Docs: docs/mcp.md (Print, Printing), docs/target-app-parity.md. --- crates/engine/src/cmd/print.rs | 7 +- crates/engine/src/tests/print.rs | 36 +++++++++ crates/eps/src/print.rs | 49 +++++++++---- crates/eps/src/print/tests.rs | 27 +++++++ crates/pdf/src/export/entry.rs | 1 + crates/pdf/src/export/writer.rs | 77 ++++++++++++-------- crates/pdf/src/print.rs | 22 +++++- crates/pdf/src/print/layout.rs | 60 ++++++++++++--- crates/pdf/src/print/settings.rs | 8 ++ crates/pdf/src/tests/print.rs | 89 +++++++++++++++++++++++ crates/ui-egui/src/dialogs/print.rs | 35 ++++++--- crates/ui-egui/src/dialogs/tests_print.rs | 20 +++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 15 +++- docs/target-app-parity.md | 1 + 33 files changed, 403 insertions(+), 74 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index f85c7b2c2..44de123fa 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Print Preview", [], None, - "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", + "{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page; with copies per sheet, the box around every copy's), copies?: [[x0, y0, x1, y1]…] (copies per sheet: each copy's artboard on the page, across then down)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing", has_doc, preview ), @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), includeArtboardFill?: true (each artboard's background color prints behind its art; false leaves it out), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy; with copiesPerSheet, a sheet holds as many copies of an artboard as fit) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -228,6 +228,7 @@ fn print(s: &mut Session, p: &Value) -> Result { view: sh.view, window: sh.window, place: sh.place, + copies: &sh.copies, area: sh.area, marks: sh.marks.as_ref(), negative: sh.negative, diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index f685e76af..a0beb2662 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -144,6 +144,41 @@ fn preview_lists_pages_tiles_inks_and_warnings() { assert_eq!(v["sheets"][3]["tile"], 4); } +#[test] +fn copies_per_sheet_print_a_grid_of_the_artboard() { + // A 2 × 3.5 in card filled by a rectangle. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + let v = s.execute("print.setup", &json!({})).unwrap(); + assert_eq!((v["settings"]["copiesPerSheet"].clone(), v["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // With an 18 pt gutter, Letter holds 3 × 3 copies; the first, at the top left, spans x 72 to 216. + let set = json!({"settings": {"copiesPerSheet": true, "gutter": 18}}); + let v = s.execute("print.preview", &set).unwrap(); + let copies = v["sheets"][0]["copies"].as_array().unwrap(); + assert_eq!((v["pages"].clone(), copies.len(), copies[0].clone()), (json!(1), 9, json!([72.0, 0.0, 216.0, 252.0]))); + // The PDF draws the card's paths nine times. + let cards = |bytes: &[u8]| { + let mut n = 0; + vectorcraft_pdf::import(bytes).unwrap().walk(|node| { + n += usize::from(node.path_data().and_then(|p| p.bounds()).is_some_and(|b| (b.width().round(), b.height().round()) == (144.0, 252.0))) + }); + n + }; + let (bytes, v) = print(&mut s, set); + assert_eq!(v["pages"], 1); + let one = cards(&print(&mut s, json!({})).0); + assert!(one > 0 && cards(&bytes) == 9 * one, "{one}"); + // PostScript places the art once per copy. + let mut ps = |p: Value| { + let v = s.execute("file.print", &p).unwrap(); + String::from_utf8(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap() + }; + let one = ps(json!({"format": "postscript"})).matches(" cm\n").count(); + let grid = ps(json!({"format": "postscript", "settings": {"copiesPerSheet": true, "gutter": 18}})).matches(" cm\n").count(); + assert_eq!(grid, one + 8); +} + #[test] fn bad_settings_are_bad_params() { let (mut s, _) = session(); @@ -154,6 +189,7 @@ fn bad_settings_are_bad_params() { json!({"settings": {"media": "quarto"}}), json!({"settings": {"artboards": "range", "range": "2"}}), json!({"settings": {"scaling": "tileFull", "overlap": 500}}), + json!({"settings": {"gutter": 800}}), ] { for c in ["file.print", "print.preview"] { assert!(matches!(s.execute(c, &p), Err(EngineError::BadParams { .. })), "{c} {p}"); diff --git a/crates/eps/src/print.rs b/crates/eps/src/print.rs index f9df227dc..99dffd822 100644 --- a/crates/eps/src/print.rs +++ b/crates/eps/src/print.rs @@ -4,7 +4,7 @@ use std::fmt::Write; -use kurbo::Shape; +use kurbo::{Shape, Vec2}; use vectorcraft_doc::{Document, Node}; use vectorcraft_geom::{Affine, Rect}; @@ -24,6 +24,9 @@ pub struct PrintPage<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the sheet: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space: what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space. @@ -59,7 +62,10 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { return Err(format!("page size {w} × {h}")); } - if !finite(p.view) || !finite(p.place) { + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if p.copies.is_empty() { &[Vec2::ZERO][..] } else { p.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { p.place } else { Affine::translate(m) * p.place }; + if !finite(p.view) || !moves.iter().all(|m| finite(place_at(*m))) { return Err("a page's placement is not finite".into()); } let opts = |region: Rect| EpsOptions { level: job.level, region, ..EpsOptions::default() }; @@ -69,6 +75,9 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { let paper = Rect::new(0.0, 0.0, w, h); let shown = if p.view.determinant().abs() > 1e-12 { p.view.inverse().transform_rect_bbox(paper) } else { paper }; let shown = p.window.map_or(shown, |t| t.intersect(shown)); + // The marks body is drawn for every copy, so it is generated for the union of the regions + // the copies show. + let shown = moves.iter().map(|m| shown - *m).reduce(|a, b| a.union(b)).unwrap_or(shown); let marks_opts = opts(shown); let marks = p.marks.map(|m| Scene::new(p.doc, &marks_opts).run_node(m)); let mut s = String::with_capacity(art.body.len() + art.setup.len() + 1024); @@ -99,19 +108,33 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec), String> { ps::push_path(&mut s, &t.to_path(0.1)); s.push_str("W\n"); } - s.push_str("q\n"); - let _ = writeln!(s, "{} cm", ps::matrix(p.place)); - ps::push_path(&mut s, &r.to_path(0.1)); - s.push_str("W\n"); - s.push_str(&art.setup); - s.push_str(&art.body); - s.push_str("Q\n"); + for (i, m) in moves.iter().enumerate() { + s.push_str("q\n"); + let _ = writeln!(s, "{} cm", ps::matrix(place_at(*m))); + ps::push_path(&mut s, &r.to_path(0.1)); + s.push_str("W\n"); + // The setup defines the spot color spaces once; each copy draws the same body. + if i == 0 { + s.push_str(&art.setup); + } + s.push_str(&art.body); + s.push_str("Q\n"); + } let mut found = vec![art]; - if let Some(m) = marks { + if let Some(marks) = marks { // The marks' spot colour spaces are defined after the art is drawn: names may be reused. - s.push_str(&m.setup); - s.push_str(&m.body); - found.push(m); + s.push_str(&marks.setup); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + let _ = writeln!(s, "q\n{} cm", ps::matrix(Affine::translate(*m))); + } + s.push_str(&marks.body); + if moved { + s.push_str("Q\n"); + } + } + found.push(marks); } s.push_str("Q\nend\nshowpage\n%%PageTrailer\n"); Ok((s, found)) diff --git a/crates/eps/src/print/tests.rs b/crates/eps/src/print/tests.rs index 171bb7700..29e7ca053 100644 --- a/crates/eps/src/print/tests.rs +++ b/crates/eps/src/print/tests.rs @@ -25,6 +25,7 @@ fn page<'a>(d: &'a Document, marks: Option<&'a Node>) -> PrintPage<'a> { view: Affine::IDENTITY, window: None, place: Affine::translate((36.0, 36.0)), + copies: &[], area: d.artboards[0].rect, marks, negative: false, @@ -67,6 +68,32 @@ fn pages_follow_dsc_and_repeat_for_copies() { assert!(back.warnings.iter().any(|w| w.contains("first of the file's 4 pages")), "{:?}", back.warnings); } +#[test] +fn copies_on_a_sheet_draw_the_art_and_marks_once_each() { + let d = doc(); + let mark = + Node::path(NodeId(9999), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::registration(), 0.25)); + let moves = [kurbo::Vec2::ZERO, kurbo::Vec2::new(218.0, 0.0), kurbo::Vec2::new(0.0, 118.0)]; + let sheet = PrintPage { copies: &moves, ..page(&d, Some(&mark)) }; + let ps = text(&print(&[sheet], &[0], &PrintJob::default()).unwrap()); + // The art is placed three times, the second 218 pt right and the third 118 pt down, and its + // spot color space and the marks' are defined once. + assert_eq!(ps.matches("[1 0 0 1 36 36] cm\n").count(), 1, "{ps}"); + assert!(ps.contains("[1 0 0 1 254 36] cm\n") && ps.contains("[1 0 0 1 36 154] cm\n"), "{ps}"); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + assert!(ps.contains("q\n[1 0 0 1 218 0] cm\n") && ps.contains("q\n[1 0 0 1 0 118] cm\n"), "{ps}"); + // A mark off the page around the first copy still prints around a later copy that has it on the page. + let look = Appearance::basic(Paint::None, Paint::registration(), 0.25); + let off = Node::path(NodeId(9998), shapes::rectangle(Rect::new(-100.0, -100.0, -90.0, -90.0)), look); + let later = [kurbo::Vec2::ZERO, kurbo::Vec2::new(200.0, 200.0)]; + let ps = text(&print(&[PrintPage { copies: &later, ..page(&d, Some(&off)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(ps.matches("/All /DeviceCMYK").count(), 1, "{ps}"); + // One copy unmoved is the page without copies. + let one = [kurbo::Vec2::ZERO]; + let single = text(&print(&[PrintPage { copies: &one, ..page(&d, Some(&mark)) }], &[0], &PrintJob::default()).unwrap()); + assert_eq!(single, text(&print(&[page(&d, Some(&mark))], &[0], &PrintJob::default()).unwrap())); +} + #[test] fn separations_screens_flatness_negatives_and_tiles() { let d = doc(); diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index c64d4a6f5..d131ece8f 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -114,6 +114,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha view, window: None, place: Affine::IDENTITY, + copies: &[], area: r, // Art reaches as far as the bleed: the marks lie outside it. clip: m != r, diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 7f9b939f9..f4a6e7f93 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -43,6 +43,9 @@ pub(crate) struct Sheet<'a> { pub window: Option, /// Document space → drawing space. pub place: Affine, + /// Copies on the page: each one's move in drawing space from where [`Self::place`] puts the + /// art. The art and the marks are drawn once per copy. Empty: one copy, not moved. + pub copies: &'a [Vec2], /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Clip the art to [`Self::area`]. @@ -127,22 +130,17 @@ impl Writer { if sheet.negative { cover(&mut s, paper, 255, 1.0); } + // `moves` holds each copy's move in drawing space; without copies, it holds one zero move. + let moves = if sheet.copies.is_empty() { &[Vec2::ZERO][..] } else { sheet.copies }; + let place_at = |m: Vec2| if m == Vec2::ZERO { sheet.place } else { Affine::translate(m) * sheet.place }; // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. - let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); + let fits = writable(&s, sheet.view, Rect::ZERO) && moves.iter().all(|m| writable(&s, sheet.view * place_at(*m), Rect::ZERO)); s.push_transform(&xf(sheet.view)); let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); if let Some(clip) = &window { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } - let placed = sheet.place != Affine::IDENTITY; - if placed { - s.push_transform(&xf(sheet.place)); - } let r = sheet.area; - let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); - if let Some(clip) = &clip { - s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); - } // Page Isolated Blending / Page Knockout Group: the page content is one group (the PDF // writer has no page group attributes; a flat PDF 1.3 file has none). In CMYK, transparency // at the top of a page is put in a group of its own, whose blending space `cmyk_blending` @@ -151,35 +149,56 @@ impl Writer { let doc = ex.doc; let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); - if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { - s.push_isolated(); - } else if cmyk_page_group { - let all = constant_mask(&mut s, r, 1.0); - s.push_mask(all); - } - if fits { - let shown = (sheet.view * sheet.place).inverse().transform_rect_bbox(paper); - ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; - ex.children(&mut s, &doc.layers, r); - } - if page_group || cmyk_page_group { - s.pop(); - } - if clip.is_some() { - s.pop(); - } - if placed { - s.pop(); + for m in moves { + let place = place_at(*m); + let placed = place != Affine::IDENTITY; + if placed { + s.push_transform(&xf(place)); + } + let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); + if let Some(clip) = &clip { + s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); + } + if page_group || (cmyk_page_group && !Node::children_blend(&doc.layers, &mut |c| c.blends_through())) { + s.push_isolated(); + } else if cmyk_page_group { + let all = constant_mask(&mut s, r, 1.0); + s.push_mask(all); + } + if fits { + let shown = (sheet.view * place).inverse().transform_rect_bbox(paper); + ex.paper = if [shown.x0, shown.y0, shown.x1, shown.y1].iter().all(|v| v.is_finite()) { shown.union(r) } else { r }; + ex.children(&mut s, &doc.layers, r); + } + if page_group || cmyk_page_group { + s.pop(); + } + if clip.is_some() { + s.pop(); + } + if placed { + s.pop(); + } } if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); + let shown = sheet.window.map_or(shown, |r| r.intersect(shown)); let knockout = std::mem::replace(&mut ex.knockout, false); // Marks keep their inks: they aren't converted to the destination. The page // information is drawn as glyph shapes, like the other marks, not as text. let convert = std::mem::replace(&mut ex.convert, false); let outline = std::mem::replace(&mut ex.outline_text, true); - ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true); + for m in moves { + let moved = *m != Vec2::ZERO; + if moved { + s.push_transform(&xf(Affine::translate(*m))); + } + ex.node(&mut s, art, shown - *m, true); + if moved { + s.pop(); + } + } (ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline); } if window.is_some() { diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 559fc6147..b11a764be 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -6,7 +6,9 @@ //! to each artboard's), transverse, which layers print (template layers never do), the //! placement on the imageable area (or where the Print Tiling tool put the pages, //! [`TileOrigin`]), and the scale: none, fit, custom, or tiles of the paper or of its imageable -//! area, overlapping, a range of them. +//! area, overlapping, a range of them. Without tiles, copies per sheet repeats the artboard +//! (with its bleed and marks) across then down the imageable area, as many times as fit, +//! `gutter` apart, and places the grid as it places one copy. //! - Marks and bleed as PDF export draws them ([`crate::MarkSettings`], [`crate::BleedSettings`]), //! around the artboard on the paper, at the paper's scale. //! - Output: composite, or separations, one page per ink that prints ([`print_inks`]) in the @@ -33,14 +35,14 @@ use std::borrow::Cow; use std::collections::HashSet; use std::sync::Arc; -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_color::cms::{self, ProfileKind}; use vectorcraft_doc::marks::PrinterMarks; use vectorcraft_doc::overprint::{clear_overprints, darken_overprints}; use vectorcraft_doc::{ColorMode, Document, Node, NodeKind}; -pub use layout::{MAX_TILES, TileGrid}; +pub use layout::{MAX_COPIES_PER_SHEET, MAX_TILES, TileGrid}; pub use plates::print_inks; pub use settings::*; pub use tiling::{TilingPage, tiling}; @@ -106,8 +108,13 @@ pub struct PageSummary { /// The part of the document this page prints (`[x0, y0, x1, y1]`, document space): the /// artboard with its bleed, or what of it a tile shows. pub area: [f64; 4], - /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`. + /// The artboard (trim box) on the page, `[x0, y0, x1, y1]`; with copies per sheet, the box + /// around every copy's. pub trim: [f64; 4], + /// With copies per sheet, each copy's artboard on the page, `[x0, y0, x1, y1]`, across then + /// down (the first is where `transform` puts it). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub copies: Vec<[f64; 4]>, } /// What a job prints, without printing it. @@ -292,6 +299,7 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result, /// Document space → drawing space. pub place: Affine, + /// With copies per sheet, each copy's move in drawing space from where [`Self::place`] puts + /// the art, across then down (the first is zero): the art and the marks are drawn once per + /// copy. Empty: one copy. + pub copies: Vec, /// The art drawn, in document space (the bleed box): what lies outside it is left out. pub area: Rect, /// Printer's marks, in drawing space (separated for the sheet's ink). @@ -334,6 +346,7 @@ impl PlannedSheet { view: self.view, window: self.window, place: self.place, + copies: &self.copies, area: self.area, clip: true, marks: self.marks.as_ref(), @@ -389,6 +402,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, view: l.view, window: l.window, place: l.place, + copies: l.copies.clone(), area: l.area, marks: art, negative: set.output.image == PrintImage::Negative, diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 21462e6b9..9002ceb80 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -1,12 +1,13 @@ //! Where each printed page's art goes: the artboards (or all the art) that print, the paper's -//! orientation, the scale, the placement on the imageable area or the tiles, and the turns and -//! mirroring of the page (flipped orientation, emulsion down, transverse). +//! orientation, the scale, the placement on the imageable area (of one copy, or of a grid of +//! copies per sheet) or the tiles, and the turns and mirroring of the page (flipped orientation, +//! emulsion down, transverse). //! //! The art is placed in a drawing space: the paper (y down from its top-left corner), or, when //! tiling, the whole printed area (the artboard grown by its bleed and marks) from its top-left //! corner, of which each page shows one tile. -use kurbo::{Affine, Rect}; +use kurbo::{Affine, Rect, Vec2}; use serde::Serialize; use vectorcraft_doc::marks::outset; use vectorcraft_doc::range::parse_range; @@ -18,6 +19,9 @@ use crate::PdfError; /// Most tiles of one artboard. pub const MAX_TILES: usize = 1000; +/// Most copies of one artboard on a sheet ([`PrintSettings::copies_per_sheet`]). +pub const MAX_COPIES_PER_SHEET: usize = 1000; + /// One page of the job, before copies and inks. #[derive(Clone, Debug)] pub(crate) struct Layout { @@ -43,9 +47,12 @@ pub(crate) struct Layout { /// the marks go. pub trim: Rect, pub bleed: [f64; 4], - /// The TrimBox and BleedBox in page space. + /// The TrimBox and BleedBox in page space (around every copy with copies per sheet). pub page_trim: Rect, pub page_bleed: Rect, + /// With copies per sheet, where each copy goes: its move in drawing space from where + /// [`Self::place`] puts the art, across then down (the first is zero). Empty: one copy. + pub copies: Vec, } /// The tiles of one artboard (`print.preview`). @@ -148,6 +155,13 @@ fn span(start: Option, extent: f64, tile: f64, step: f64, overlap: f64) -> } } +/// How many printed areas `extent` long fit in `room`, `gutter` apart (whole copies only). +fn fitting(room: f64, extent: f64, gutter: f64) -> f64 { + // Copies that fill the room exactly fit. + const EPS: f64 = 1e-6; + ((room + gutter) / (extent + gutter) + EPS).floor().max(0.0) +} + /// The page turns of a `w` × `h` paper (flipped, emulsion down, transverse) as one transform from /// the paper onto the page, and the page's size. fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f64, f64)) { @@ -238,7 +252,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec 0.0 { (t, b) } else { (page, page) } }; - let base = |view, window, place, area, trim, (page_trim, page_bleed), tile| Layout { + let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout { artboard, tile, size: page_size, @@ -252,24 +266,48 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { + warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + None + } + Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { + return Err(PdfError::BadSetting(format!("{what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up"))); + } + fit => fit.map(|(cols, rows)| (cols as usize, rows as usize)), + }; + let step = (extent.0 + gutter, extent.1 + gutter); + let all = grid.map_or(extent, |(cols, rows)| (cols as f64 * step.0 - gutter, rows as f64 * step.1 - gutter)); + // The printed area's (or the grid's) top-left corner: where the tile origin puts it, + // else the placement. let (x, y) = match origin { Some((ox, oy)) => (imageable.x0 - ox * scale.0 - most[2], imageable.y0 - oy * scale.1 - most[0]), None => { let (fx, fy) = set.placement.origin.factors(); ( - imageable.x0 + (imageable.width() - extent.0) * fx + set.placement.x, - imageable.y0 + (imageable.height() - extent.1) * fy + set.placement.y, + imageable.x0 + (imageable.width() - all.0) * fx + set.placement.x, + imageable.y0 + (imageable.height() - all.1) * fy + set.placement.y, ) } }; let trim = Rect::new(x + most[2], y + most[0], x + most[2] + size.0, y + most[0] + size.1); - if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + extent.0, y + extent.1)) { + if !imageable.inflate(0.01, 0.01).contains_rect(Rect::new(x, y, x + all.0, y + all.1)) { warnings.push(format!("{what} with its bleed and marks is larger than the imageable area: part of it doesn't print")); } - pages.push(base(turn, None, place_at(trim), bleed_box, trim, boxes(turn, trim), None)); + let copies: Vec = grid.map_or_else(Vec::new, |(cols, rows)| { + (0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect() + }); + let page_boxes = + copies.iter().map(|m| boxes(turn, trim + *m)).reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))).unwrap_or_else(|| boxes(turn, trim)); + pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies)); continue; } // Tiles across then down over the printed area, overlapping by `overlap`. @@ -312,7 +350,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec Vec<(f64, f64)> { + pv.sheets[0].copies.iter().map(|c| (c[0], c[1])).collect() +} + +/// Every `(x, y)` of `xs` and `ys`, across then down. +fn grid(xs: &[f64], ys: &[f64]) -> Vec<(f64, f64)> { + ys.iter().flat_map(|y| xs.iter().map(move |x| (*x, *y))).collect() +} + +#[test] +fn copies_per_sheet_fill_the_page_in_a_grid() { + // A 2 × 3.5 in card (144 × 252 pt) on Letter (612 × 792 pt): 4 across and 3 down, the + // 576 × 756 pt grid centered. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap(); + assert_eq!((pv.pages, pv.sheets.len(), pv.sheets[0].orientation), (1, 1, Orientation::Portrait)); + assert_eq!(corners(&pv), grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0])); + assert!(pv.sheets[0].copies.iter().all(|c| (c[2] - c[0], c[3] - c[1]) == (144.0, 252.0)), "{:?}", pv.sheets[0].copies); + assert_eq!(pv.sheets[0].trim, [18.0, 18.0, 594.0, 774.0], "the TrimBox holds every copy"); + assert!(pv.warnings.is_empty(), "{:?}", pv.warnings); + // The printed page holds the twelve cards there. + let p = pages(&job(&d, json!({"copiesPerSheet": true})).bytes); + assert_eq!(p.len(), 1); + let mut printed: Vec<(f64, f64)> = p[0].1.iter().map(|(r, _)| (r.x0.round(), r.y0.round())).collect(); + printed.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))); + assert_eq!(printed, grid(&[18.0, 162.0, 306.0, 450.0], &[18.0, 270.0, 522.0]), "{:?}", p[0].1); + assert!(p[0].1.iter().all(|(r, paint)| rgb8(paint) == [255, 0, 0] && near(*r, Rect::new(r.x0, r.y0, r.x0 + 144.0, r.y0 + 252.0)))); + // An 18 pt gutter: 3 across and 3 down, the 468 × 792 pt grid centered. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 18}))).unwrap(); + assert_eq!(corners(&pv), grid(&[72.0, 234.0, 396.0], &[0.0, 270.0, 540.0])); + // The placement places the grid, inside the margin. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap(); + assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0])); +} + +#[test] +fn copies_per_sheet_is_off_by_default_and_off_prints_as_before() { + let s = PrintSettings::default(); + assert_eq!((s.copies_per_sheet, s.gutter), (false, 0.0)); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!((&v["copiesPerSheet"], &v["gutter"]), (&json!(false), &json!(0.0))); + assert!(settings(json!({"gutter": -1})).check().is_err() && settings(json!({"gutter": 721})).check().is_err()); + // Off, the job is the same file whatever the gutter, and its pages list no copies. + let d = doc(144.0, 252.0, &[(Rect::new(0.0, 0.0, 144.0, 252.0), rgb(1.0, 0.0, 0.0))]); + assert_eq!(job(&d, json!({})).bytes, job(&d, json!({"copiesPerSheet": false, "gutter": 36})).bytes); + let pv = serde_json::to_value(preview(&d, &settings(json!({}))).unwrap()).unwrap(); + assert!(pv["sheets"][0].get("copies").is_none(), "{pv}"); + // Tiles print one artboard across pages: copies per sheet is left out. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "scaling": "tileFull"}))).unwrap(); + assert!(pv.sheets[0].copies.is_empty() && pv.sheets[0].tile.is_some()); +} + +#[test] +fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { + // 700 × 900 pt is larger than Letter either way round. + let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); + let r = job(&d, json!({"copiesPerSheet": true})); + assert_eq!(r.pages, 1); + assert!( + r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), + "{:?}", + r.warnings + ); + assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); + assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); + assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); + // When one copy fits, it prints alone, centered, whatever the gutter. + let d = doc(400.0, 500.0, &[]); + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "gutter": 300}))).unwrap(); + assert_eq!(corners(&pv), [(106.0, 146.0)]); + // More copies than a sheet takes are refused. + let tiny = doc(1.0, 1.0, &[]); + assert!(preview(&tiny, &settings(json!({"copiesPerSheet": true}))).is_err_and(|e| e.to_string().contains("more than 1000 copies"))); +} + +#[test] +fn each_copy_prints_with_its_marks() { + let d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 100.0, 100.0), rgb(1.0, 0.0, 0.0))]); + let set = json!({"copiesPerSheet": true, "marks": {"trim": true}}); + let pv = preview(&d, &settings(set.clone())).unwrap(); + let copies = pv.sheets[0].copies.len(); + assert!(copies > 1, "{pv:?}"); + let back = import(&job(&d, set).bytes).unwrap(); + let mut marks = 0; + back.walk(|n| marks += usize::from(n.appearance.stroke().is_some_and(|s| s.paint.is_registration()))); + assert_eq!(marks, 8 * copies, "8 trim marks around each copy"); +} + #[test] fn tiles_cover_the_artboard_with_overlap() { // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 523c42d06..807beed25 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -427,22 +427,32 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: } let coeffs: Vec = sheet["transform"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let clip = ui.painter().with_clip_rect(page); + // With copies per sheet, `copies` holds each copy's artboard on the page, and `moves` holds + // each copy's move on screen from the first. + let copies: Vec = sheet["copies"].as_array().map(|a| a.iter().filter_map(rect_of).collect()).unwrap_or_default(); + let moves: Vec = match copies.first() { + Some(first) => copies.iter().map(|r| egui::vec2((r.x0 - first.x0) as f32, (r.y0 - first.y0) as f32) * fit).collect(), + None => vec![egui::Vec2::ZERO], + }; if let ([a, b, c, dd, e, f], Some(area)) = (coeffs.as_slice(), rect_of(&sheet["area"])) { let px = (f64::from(page.width().max(page.height()) * ui.ctx().pixels_per_point())).min(1024.0); let layers = choice::(d, "printLayers").unwrap_or(PrintLayers::VisiblePrintable); if let Some(tex) = art_texture(app, ui.ctx(), layers, area, px) { let at = |x: f64, y: f64| to_screen(a * x + c * y + e, b * x + dd * y + f) + offset; let mut mesh = egui::Mesh::with_texture(tex.id()); - for (p, uv) in [ - ((area.x0, area.y0), (0.0, 0.0)), - ((area.x1, area.y0), (1.0, 0.0)), - ((area.x1, area.y1), (1.0, 1.0)), - ((area.x0, area.y1), (0.0, 1.0)), - ] { - mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1), uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + for m in &moves { + let first = mesh.vertices.len() as u32; + for (p, uv) in [ + ((area.x0, area.y0), (0.0, 0.0)), + ((area.x1, area.y0), (1.0, 0.0)), + ((area.x1, area.y1), (1.0, 1.0)), + ((area.x0, area.y1), (0.0, 1.0)), + ] { + mesh.vertices.push(egui::epaint::Vertex { pos: at(p.0, p.1) + *m, uv: egui::pos2(uv.0, uv.1), color: egui::Color32::WHITE }); + } + mesh.add_triangle(first, first + 1, first + 2); + mesh.add_triangle(first, first + 2, first + 3); } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); clip.add(mesh); } } @@ -460,7 +470,9 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; clip.extend(egui::Shape::dashed_line(&corners, stroke, 3.0, 2.0)); } - if let Some(trim) = rect_of(&sheet["trim"]) { + // The preview strokes each copy's artboard edge, or the trim box without copies per sheet. + let trims = if copies.is_empty() { rect_of(&sheet["trim"]).into_iter().collect() } else { copies }; + for trim in trims { let r = egui::Rect::from_min_max(to_screen(trim.x0, trim.y0), to_screen(trim.x1, trim.y1)).translate(offset); clip.rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Middle); } @@ -660,6 +672,9 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { if tiles { note(ui, tl!("Tiles are numbered across, then down; leave the range empty for every tile.")); } + flag(ui, d, "copiesPerSheet", tl!("Copies per Sheet (as many as fit, in a grid)"), !tiles); + let per_sheet = get(d, "copiesPerSheet").as_bool() == Some(true); + row(ui, tl!("Gutter:"), |ui| length(ui, d, "gutter", unit, !tiles && per_sheet)); } fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { diff --git a/crates/ui-egui/src/dialogs/tests_print.rs b/crates/ui-egui/src/dialogs/tests_print.rs index bf213c789..3bf847d0e 100644 --- a/crates/ui-egui/src/dialogs/tests_print.rs +++ b/crates/ui-egui/src/dialogs/tests_print.rs @@ -216,6 +216,24 @@ fn without_a_print_service_print_saves_a_pdf() { assert_eq!(app.run("print.printers", json!({})).unwrap(), json!({"printers": [], "service": false, "setup": false})); } +#[test] +fn copies_per_sheet_prints_a_grid_of_each_artboard() { + let (mut app, jobs) = app(); + app.run("file.print", json!({})).unwrap(); + set(&mut app, "copiesPerSheet", json!(true)); + set(&mut app, "gutter", json!(9)); + frame(&mut app); + let r = super::confirm(&mut app).unwrap(); + assert_eq!(r["pages"], 3, "a sheet per artboard"); + assert_eq!(pages(&jobs.borrow()[0].2), 3); + let saved = app.session.active().unwrap().doc.print_setup.clone().unwrap(); + assert_eq!((saved["copiesPerSheet"].clone(), saved["gutter"].clone()), (json!(true), json!(9.0))); + // Each 120 × 90 artboard fills landscape Letter 6 across and 6 down, 9 pt apart. + let pv = app.session.execute("print.preview", &json!({})).unwrap(); + assert_eq!(pv["sheets"][0]["copies"].as_array().map(Vec::len), Some(36)); + assert_eq!(pv["sheets"][0]["copies"][1], json!([142.5, 13.5, 262.5, 103.5])); +} + #[test] fn dragging_the_preview_moves_the_placement_on_the_paper() { let (mut app, _) = app(); @@ -225,6 +243,8 @@ fn dragging_the_preview_moves_the_placement_on_the_paper() { (json!({"autoRotate": false, "orientation": "portraitFlipped"}), (10.0, 5.0)), (json!({"output": {"emulsion": "down"}, "transverse": true}), (-4.0, 7.0)), (json!({"scaling": "custom", "scale": {"width": 50, "height": 200}}), (6.0, -8.0)), + // Copies per sheet: the drag moves the whole grid. + (json!({"copiesPerSheet": true, "gutter": 9}), (10.0, 5.0)), ] { let before = app.session.execute("print.preview", &json!({ "settings": settings })).unwrap(); let sheet = &before["sheets"][0]; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a849c4abc..fc41498e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3459,6 +3459,7 @@ digits Malayalam Малаялам Tile Type: Тип плочка: tile {number} плочка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плочките се номерират напречно, след това надолу; оставете диапазона празен за всяка плочка. + Copies per Sheet (as many as fit, in a grid) Копия на лист (колкото се побират, в мрежа) Tint Оттенък Tip extends past the end point Връхът продължава след крайната точка Tip on the end point Връх върху крайната точка diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cdab9e994..56271636d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2710,6 +2710,7 @@ digits Malayalam Malajálamské Tile Range: Rozsah dlaždic: tile {number} dlaždice {number} Tiles are numbered across, then down; leave the range empty for every tile. Dlaždice se číslují po řádcích, pak dolů; prázdný rozsah znamená všechny dlaždice. + Copies per Sheet (as many as fit, in a grid) Kopie na list (kolik se vejde, v mřížce) Transverse (the page a quarter turn on the paper) Napříč (stránka otočená na papíře o čtvrt otáčky) Unprintable margin: Netisknutelný okraj: {pages} in all, {count} per copy celkem {pages}, na kopii {count} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9e1ac89a6..c19acf1fb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3491,6 +3491,8 @@ effect Points Punkte Tile Type: Kacheltyp: tile {number} Kachel {number} Tiles are numbered across, then down; leave the range empty for every tile. Kacheln werden erst quer, dann nach unten nummeriert; ein leerer Bereich steht für alle Kacheln. + Copies per Sheet (as many as fit, in a grid) Kopien pro Blatt (so viele wie passen, im Raster) + Gutter: Zwischenraum: Tint Tonwert Tip extends past the end point Spitze ragt über den Endpunkt hinaus Tip on the end point Spitze auf dem Endpunkt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7d3ddc4c5..30f2da1ef 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3883,6 +3883,7 @@ digits Malayalam Μαλαγιάλαμ Tile Type: Τύπος πλακιδίου: tile {number} πλακίδιο {number} Tiles are numbered across, then down; leave the range empty for every tile. Τα πλακίδια αριθμούνται πρώτα οριζόντια και έπειτα κατακόρυφα· αφήστε κενό το εύρος για όλα τα πλακίδια. + Copies per Sheet (as many as fit, in a grid) Αντίγραφα ανά φύλλο (όσα χωρούν, σε πλέγμα) Tip extends past the end point Η αιχμή προεξέχει από το τελικό σημείο Tip on the end point Η αιχμή βρίσκεται στο τελικό σημείο Title Case Κεφαλαίο σε κάθε λέξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bde3d5ea6..98f7f02c4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3547,6 +3547,8 @@ digits Malayalam Malayálam Tile Type: Tipo de mosaico: tile {number} mosaico {number} Tiles are numbered across, then down; leave the range empty for every tile. Los mosaicos se numeran de izquierda a derecha y luego hacia abajo; deja el rango vacío para todos. + Copies per Sheet (as many as fit, in a grid) Copias por hoja (tantas como quepan, en cuadrícula) + Gutter: Medianil: Tint Matiz Tip extends past the end point La punta se extiende más allá del punto final Tip on the end point Punta en el punto final diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 66c944a2e..458a07d94 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2654,6 +2654,7 @@ digits Malayalam Malajalam Tile Range: Ruutualue: tile {number} ruutu {number} Tiles are numbered across, then down; leave the range empty for every tile. Ruudut numeroidaan vaakasuunnassa ja sitten alaspäin; jätä alue tyhjäksi, niin kaikki ruudut tulostetaan. + Copies per Sheet (as many as fit, in a grid) Kopioita arkille (niin monta kuin mahtuu, ruudukkoon) Transverse (the page a quarter turn on the paper) Poikittainen (sivu neljänneskierroksen käännettynä paperilla) Unprintable margin: Tulostumaton reunus: {pages} in all, {count} per copy {pages} yhteensä, {count} kopiota kohden diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f899e59bd..888572ed4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3538,6 +3538,7 @@ digits Malayalam Malayalam Tile Range: Plage de carreaux : Tile Type: Type de carreau : tile {number} carreau {number} + Copies per Sheet (as many as fit, in a grid) Copies par feuille (autant qu’il en tient, en grille) Tiles are numbered across, then down; leave the range empty for every tile. Les carreaux sont numérotés de gauche à droite, puis de haut en bas ; laissez la plage vide pour tous les carreaux. Tint Teinte Tip extends past the end point La pointe dépasse le point d’extrémité diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f059e26eb..9b90d2214 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4221,6 +4221,7 @@ mark-style Japanese Japán tile {number} csempe {number} Tiles are numbered across, then down; leave the range empty for every tile. A csempék számozása vízszintesen, majd lefelé halad; az összes csempéhez hagyd üresen a tartományt. Tilt Dőlés + Copies per Sheet (as many as fit, in a grid) Példány laponként (ahány elfér, rácsban) Tint Tónus Tints Tónusok Tints and Shades Tónusok és árnyalatok diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index aba5095e4..fa2126d0d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3583,6 +3583,8 @@ digits Malayalam Malayalam Tile Type: Tipo di tassello: tile {number} riquadro {number} Tiles are numbered across, then down; leave the range empty for every tile. I riquadri sono numerati da sinistra a destra, poi dall'alto in basso; lascia vuoto l'intervallo per stamparli tutti. + Copies per Sheet (as many as fit, in a grid) Copie per foglio (quante ne entrano, in griglia) + Gutter: Spazio intermedio: Tint Tinta Tip extends past the end point La punta si estende oltre il punto finale Tip on the end point Punta sul punto finale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8f642c98b..6596dc515 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3275,6 +3275,8 @@ mark-style Japanese 日本式 Tile Range: 分割範囲: Tile Type: タイルの種類: Tiles are numbered across, then down; leave the range empty for every tile. タイルは左から右、上から下の順に番号が付きます。範囲を空にするとすべてのタイルが対象です。 + Copies per Sheet (as many as fit, in a grid) 用紙ごとに複数コピー (収まるだけグリッド状に配置) + Gutter: 間隔: Toggle Fill/Stroke Focus 塗り/線の切り替え Toggle Selection 選択を切り替え Toggle single/double column 1 列/2 列を切り替え diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 35627c507..d40d786f6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3264,6 +3264,8 @@ mark-style Japanese 일본식 Tile Range: 타일 범위: Tile Type: 타일 유형: Tiles are numbered across, then down; leave the range empty for every tile. 타일은 가로로 먼저, 이어서 세로로 번호가 매겨집니다. 모든 타일을 지정하려면 범위를 비워 두십시오. + Copies per Sheet (as many as fit, in a grid) 용지당 여러 매 (들어가는 만큼 격자로 배치) + Gutter: 간격: Toggle Fill/Stroke Focus 칠/선 포커스 전환 Toggle Selection 선택 전환 Toggle single/double column 한 줄/두 줄 전환 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd5355d48..feb7ccc1d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2623,6 +2623,8 @@ digits Malayalam Malajalam Tile Range: Zakres kafelków: tile {number} kafelek {number} Tiles are numbered across, then down; leave the range empty for every tile. Kafelki są numerowane poziomo, a potem w dół; pozostaw zakres pusty dla wszystkich kafelków. + Copies per Sheet (as many as fit, in a grid) Kopie na arkusz (tyle, ile się zmieści, w siatce) + Gutter: Odstęp: Transverse (the page a quarter turn on the paper) Poprzecznie (strona obrócona o ćwierć obrotu na papierze) Unprintable margin: Margines niedrukowalny: {pages} in all, {count} per copy Razem: {pages}, na kopię: {count} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7ea0725c0..ae7303f24 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2916,6 +2916,8 @@ digits Malayalam Malaiala Tile Range: Intervalo de Blocos: Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. + Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) + Gutter: Medianiz: Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ed2ab3ae4..b4b6cad19 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3578,6 +3578,8 @@ digits Malayalam Малаялам Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. + Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) + Gutter: Промежуток: Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 494641000..48f235d7a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3469,6 +3469,7 @@ digits Malayalam Malayalam Tile Type: Ruttyp: tile {number} ruta {number} Tiles are numbered across, then down; leave the range empty for every tile. Rutorna numreras från vänster till höger och sedan nedåt. Lämna omfånget tomt för att få med alla rutor. + Copies per Sheet (as many as fit, in a grid) Kopior per ark (så många som får plats, i ett rutnät) Tint Färgton Tip extends past the end point Spetsen sträcker sig förbi slutpunkten Tip on the end point Spetsen på slutpunkten diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 91e88343f..8e553ea23 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3576,6 +3576,8 @@ effect Points Точки Tile Type: Тип плитки: tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруються зліва направо, потім униз; Залишіть діапазон порожнім для всіх плиток. + Copies per Sheet (as many as fit, in a grid) Копій на аркуш (скільки поміститься, сіткою) + Gutter: Проміжок: Tint Відтінок Tip extends past the end point Кінчик виходить за кінцеву точку Tip on the end point Кінчик на кінцевій точці diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2f37a6e87..4ec44a54f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3923,6 +3923,8 @@ mark-style Japanese Kiểu Nhật tile {number} ô {number} Tiles are numbered across, then down; leave the range empty for every tile. Các ô được đánh số từ ngang sang dọc; để trống phạm vi để in tất cả ô. Tilt Độ nghiêng + Copies per Sheet (as many as fit, in a grid) Nhiều bản sao trên một tờ (xếp vừa bao nhiêu in bấy nhiêu, theo lưới) + Gutter: Khoảng cách: Tint Tông màu Tints Sắc pha Tints and Shades Sắc pha và bóng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9e470c0bd..6a3af5217 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3414,6 +3414,7 @@ digits Malayalam 马拉雅拉姆数字 Tile Type: 拼贴类型: tile {number} 拼贴 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼贴先由左至右再由上至下编号;范围留空表示全部拼贴。 + Copies per Sheet (as many as fit, in a grid) 每张纸印多份 (尽量排满,按网格排列) Tint 色调 Tip extends past the end point 箭头超出端点 Tip on the end point 箭头位于端点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7b5fa5e50..201289d2c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4461,6 +4461,8 @@ digits Malayalam 馬來亞拉姆數字 Tile Type: 拼貼類型: tile {number} 拼貼 {number} Tiles are numbered across, then down; leave the range empty for every tile. 拼貼先由左至右再由上至下編號;範圍留空表示全部拼貼。 + Copies per Sheet (as many as fit, in a grid) 每張紙印多份 (盡量排滿,以格點排列) + Gutter: 間距: Tint 色調 Tip extends past the end point 尖端延伸超過終點 Tip on the end point 尖端位於終點 diff --git a/docs/mcp.md b/docs/mcp.md index b4ca733f8..a880c6f3a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5155,6 +5155,12 @@ documents every field: visiblePrintable|visible|all` (template layers never print); `placement: {origin, x, y}` places the printed area (artboard, bleed and marks) on the imageable area (the paper inside `margin`); `scaling: none|fit|custom|tileFull| tileImageable` with `scale: {width, height}` (%), `overlap` and `tileRange` (tiles numbered across then down). +- Copies per sheet: with `copiesPerSheet: true` (off by default; not with tiling) each sheet holds as many copies of + the artboard as fit the imageable area, each with its bleed and marks, across then down, `gutter` pt apart (0–720, + default 0). The copies are not turned, and the placement places the whole grid. An artboard that doesn't fit prints + one copy per sheet, with a warning; more than 1,000 copies on a sheet is an error. `print.preview` lists each + copy's artboard on the page as `copies: [[x0, y0, x1, y1]…]`, across then down, and `trim` (the TrimBox) holds + them all. - `marks` and `bleed` as `document.exportPdf` takes them (the bleed is the document's unless `useDocument: false`), drawn around the artboard at the paper's scale; the page information adds the tile and the ink with its screen. - `output: {mode: composite|separations, emulsion: up|down, image: positive|negative, spotsToProcess, inks: [{name, @@ -5444,10 +5450,11 @@ again when it changes); Multichannel files are refused. ## Printing `print.setup {settings?}` keeps print settings with the document (one undo step; without `settings` it answers the -current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, marks and bleed, composite or -separations output, graphics and colour management options of File › Print. `print.preview {settings?}` lays the job -out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the `transform` from the -document onto the page (pt, y down), the document `area` it prints and its `trim` box on the page, plus `tiles`, the +current ones): the copies, artboards, paper, orientation, placement, scaling and tiling, copies per sheet, marks and +bleed, composite or separations output, graphics and colour management options of File › Print. `print.preview +{settings?}` lays the job out without printing: `pages`, then one copy's `sheets`, each with its page size, scale, the +`transform` from the document onto the page (pt, y down), the document `area` it prints, its `trim` box on the page +and, with copies per sheet, each copy's artboard on the page (`copies`), plus `tiles`, the `inks` of a separation and `warnings`. `file.print {settings?, path?}` writes the job as a print-ready PDF (one page per sheet, tile, ink and copy); headless it answers `dataBase64`. In the app, `file.print` with no params opens the Print dialog, and with params it prints through the system (`printer`: a name from `print.printers`, default the system's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f95308865..6c41419d9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1400,6 +1400,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export and PDF-compatible `.ai` and `.ait` saves write File Info's copyright notice, URL and status to the XMP packet (`dc:rights`, `xmpRights:WebStatement`, `xmpRights:Marked`), so a PDF or `.ai` opened from its pages keeps them (`crates/pdf/src/tests/file_info.rs`, `crates/engine/src/tests/fileinfo.rs`). - Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests/file_info.rs`). - `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets. + - Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283). - Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457). - File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`). From b9f735dd9a587857cf4e7b7e4c3e1b7bfb7848f5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:23:16 -0700 Subject: [PATCH 09/23] i18n: Dutch, Danish and Indonesian rows for Copies per Sheet main now lists nl, da and id as complete-menu catalogs, so the Print dialog's Copies per Sheet label needs a row in each. Gutter: already has one. --- crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8174bad84..2a6be7c3a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2498,6 +2498,7 @@ digits Malayalam Malayalam Tile Range: Fliseinterval: tile {number} flise {number} Tiles are numbered across, then down; leave the range empty for every tile. Fliser nummereres på tværs og derefter nedad; lad intervallet stå tomt for alle fliser. + Copies per Sheet (as many as fit, in a grid) Kopier pr. ark (så mange der er plads til, i et gitter) Transverse (the page a quarter turn on the paper) På tværs (siden drejes en kvart omgang på papiret) Unprintable margin: Ikke-udskrivbar margen: {pages} in all, {count} per copy {pages} i alt, {count} pr. kopi diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0d50d0e1..88247c6f8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2498,6 +2498,7 @@ digits Malayalam Malayalam Tile Range: Rentang ubin: tile {number} ubin {number} Tiles are numbered across, then down; leave the range empty for every tile. Ubin dinomori menyamping lalu ke bawah; biarkan rentang kosong untuk semua ubin. + Copies per Sheet (as many as fit, in a grid) Salinan per lembar (sebanyak yang muat, dalam kisi) Transverse (the page a quarter turn on the paper) Melintang (halaman diputar seperempat putaran pada kertas) Unprintable margin: Margin yang tidak dapat dicetak: {pages} in all, {count} per copy {pages} total, {count} per salinan diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b7e18b42c..b78c94e83 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2512,6 +2512,7 @@ digits Malayalam Malayalam Tile Range: Tegelbereik: tile {number} tegel {number} Tiles are numbered across, then down; leave the range empty for every tile. Tegels worden van links naar rechts en vervolgens van boven naar beneden genummerd; laat het bereik leeg voor alle tegels. + Copies per Sheet (as many as fit, in a grid) Kopieën per vel (zoveel als er passen, in een raster) Transverse (the page a quarter turn on the paper) Dwars (de pagina een kwartslag op het papier) Unprintable margin: Niet afdrukbare marge: {pages} in all, {count} per copy {pages} in totaal, {count} per exemplaar From 3de6da24be02abfad47c3925d1a9a27c4fc94431 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:06:41 -0800 Subject: [PATCH 10/23] Docs: a delete of a key with no value writes no settings file --- docs/plugins.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index d7eb95694..00725a887 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -276,8 +276,8 @@ doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A -set to the value the key already has writes nothing. Every other set or delete writes the file before the call -returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and the run waits for it. +set to the value the key already has writes nothing, and neither does a delete of a key that has no value. Every +other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, From 9a2cb3b25706430d597400b42e27f292cdf875d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:06:46 -0800 Subject: [PATCH 11/23] Docs: a plug-in with no permissions list needs no grant The Plug-in grants cover one module line said every module with other bytes needs a new approval. A module whose permissions list is missing or empty needs no grant, and without the list it gets its kind's implied permissions. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 15f5161f1..579866738 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1567,7 +1567,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. -- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. From ac076655db7ca5b4c5df9bfa6ac7a3b03e3c5f7c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:12:22 -0800 Subject: [PATCH 12/23] Updates: the updater reports installing, ready, installed and failed, and installs at exit craft-update moves from c5a1cbfb to the head of storytold/craft-libs#696. The check result window now shows for every status but idle, so the first start after an update reports that it installed or why it failed. Restart to Update on a ready update asks the app to close. The install helper now starts when the app exits. on_exit calls Updater::start_pending_update through VectorcraftApp, because the macOS exit in end_before_teardown ends the process without dropping the app. --- Cargo.lock | 15 +------- Cargo.toml | 2 +- apps/vectorcraft/src/app.rs | 3 ++ crates/ui-egui/src/updates_ui.rs | 62 +++++++++++++++++++++++--------- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee3905a8a..a22c489f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/dotCooCoo/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" dependencies = [ "chrono", "dunce", @@ -8040,7 +8040,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8055,18 +8054,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index a3e3e9890..8a1ee4ab4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/dotCooCoo/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 2dfc4e6f4..f47b10e68 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -92,6 +92,9 @@ impl eframe::App for App { } fn on_exit(&mut self) { file_access::unconfined(|| save_prefs(&self.app)); + // A ready update installs once the process has exited. The macOS exit below skips + // dropping the app, so the helper is started here. + self.app.start_pending_update(); end_before_teardown(self.frames); } } diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index a62a221c5..c8dade12b 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -5,7 +5,7 @@ use serde_json::Value; use crate::VectorcraftApp; #[cfg(not(target_arch = "wasm32"))] -use craft_update::updater::{Action, Updater}; +use craft_update::updater::{Action, Status, Updater}; #[cfg(not(target_arch = "wasm32"))] use craft_update::{CheckResultDialog, CheckResultResponse}; @@ -20,10 +20,10 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option, - /// Whether the result of Help ▸ Check for Updates is in view. - pub(crate) manual_window_open: bool, /// The web build has no updater; its window only says so. #[cfg(target_arch = "wasm32")] + pub(crate) manual_window_open: bool, + #[cfg(target_arch = "wasm32")] pub(crate) status_message: Option, } @@ -38,6 +38,7 @@ impl Default for Runtime { check_requested: false, #[cfg(not(target_arch = "wasm32"))] repaint: None, + #[cfg(target_arch = "wasm32")] manual_window_open: false, #[cfg(target_arch = "wasm32")] status_message: None, @@ -51,13 +52,20 @@ impl VectorcraftApp { pub fn set_startup_update_check(&mut self, on: bool) { self.updates_rt.startup_check = on; } + + /// Starts the install helper of an update that is ready, so it installs once the process has + /// exited (does nothing when no update is ready). Call it in `on_exit` when the process ends + /// there, since the app is then never dropped. + #[cfg(not(target_arch = "wasm32"))] + pub fn start_pending_update(&mut self) { + self.updates_rt.updater.start_pending_update(); + } } pub(crate) fn check(app: &mut VectorcraftApp) -> Result { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; - app.updates_rt.manual_window_open = true; if let Some(ctx) = &app.updates_rt.repaint { ctx.request_repaint(); } @@ -87,10 +95,18 @@ pub(crate) fn tick(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = (app, ctx); } +/// Whether the check result window shows: for every status but idle (a requested check, and the +/// first start after an update, which reports that it installed or why it failed), and never +/// over the startup prompt. +#[cfg(not(target_arch = "wasm32"))] +fn result_window_shows(status: &Status, prompt_open: bool) -> bool { + !prompt_open && !matches!(status, Status::Idle) +} + pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + // The close for a ready update waits until no document holds unsaved work. let clean = !crate::unsaved::any_dirty(app); match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => app.open_url(&url), @@ -99,20 +115,15 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } // An available update goes to the prompt above, which replaces the result dialog. let rt = &mut app.updates_rt; - if rt.manual_window_open && rt.updater.prompt_open() { - rt.manual_window_open = false; - } - if rt.manual_window_open { + if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) { match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) { CheckResultResponse::None => {} - CheckResultResponse::Close => rt.manual_window_open = false, + // Closing the dialog dismisses the outcome it showed. + CheckResultResponse::Close => rt.updater.dismiss_status(), CheckResultResponse::CheckAgain => rt.updater.check_now(ctx), + CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(), } } - // Closing the dialog dismisses the outcome it showed. - if !rt.manual_window_open { - rt.updater.dismiss_status(); - } } #[cfg(target_arch = "wasm32")] @@ -142,13 +153,12 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests { use super::*; - #[cfg(not(target_arch = "wasm32"))] - use craft_update::updater::Status; use vectorcraft_engine::Session; #[test] fn default_runtime_is_idle() { let rt = Runtime::default(); + #[cfg(target_arch = "wasm32")] assert!(!rt.manual_window_open); #[cfg(not(target_arch = "wasm32"))] { @@ -158,6 +168,26 @@ mod tests { } } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn result_window_shows_every_status_but_idle() { + let shown = [ + Status::Checking, + Status::UpToDate, + Status::Failed("offline".into()), + Status::Installing, + Status::Ready, + Status::Managed, + Status::Updated("99.0.0".into()), + Status::InstallFailed("locked".into()), + ]; + for status in &shown { + assert!(result_window_shows(status, false), "{status:?} shows"); + assert!(!result_window_shows(status, true), "{status:?} waits for the prompt"); + } + assert!(!result_window_shows(&Status::Idle, false)); + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn startup_prompt_shows_and_closes() { From 967d77919f2de04edd9a42236353bd08740cabf2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:56:39 -0700 Subject: [PATCH 13/23] Print: every copy on a sheet draws the same symbol instances Each copy on a sheet draws within an equal share of the symbol objects the export has left (MAX_SYMBOL_NODES), so a document near the limit leaves out the same instances in every copy instead of dropping them from the later copies. The limit stays export-wide; one copy per page draws as before. --- crates/pdf/src/export.rs | 3 ++ crates/pdf/src/export/exporter.rs | 1 + crates/pdf/src/export/nodes.rs | 29 ++++++++++-------- crates/pdf/src/export/writer.rs | 6 ++++ crates/pdf/src/tests/print.rs | 50 +++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 8728b29b2..47184d520 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -118,6 +118,9 @@ pub(crate) struct Exporter<'a> { /// Objects the symbol instances written held, together /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). symbol_nodes: u64, + /// The most [`Self::symbol_nodes`] may reach: [`vectorcraft_doc::MAX_SYMBOL_NODES`], or less + /// while one of the copies on a sheet is drawn ([`Writer::page`]). + pub(crate) symbol_limit: u64, } #[cfg(test)] diff --git a/crates/pdf/src/export/exporter.rs b/crates/pdf/src/export/exporter.rs index ed5f984c0..eb312021e 100644 --- a/crates/pdf/src/export/exporter.rs +++ b/crates/pdf/src/export/exporter.rs @@ -47,6 +47,7 @@ impl<'a> Exporter<'a> { in_layer: 0, paper: Rect::ZERO, symbol_nodes: 0, + symbol_limit: vectorcraft_doc::MAX_SYMBOL_NODES, overprint: false, overprinted: false, invisible: false, diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index e8057236c..dbf4b0d87 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -272,20 +272,23 @@ impl Exporter<'_> { let count = art.count() as u64; let map = doc.symbol_slice_map(symbol, *xf); let most = map.map_or(count, |m| m.most_placed(art, doc)); - let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { - let art = match map { - Some(m) => m.place(art, doc), - None => { - let mut a = art.clone(); - a.transform(*xf, false); - a - } - }; - // As `most_placed`, placed art counts its opacity-mask art. - let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; - (art, count) + let within = self.symbol_nodes.saturating_add(most) <= self.symbol_limit; + let placed = within.then(|| { + vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(art, doc), + None => { + let mut a = art.clone(); + a.transform(*xf, false); + a + } + }; + // As `most_placed`, placed art counts its opacity-mask art. + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }) }); - if let Some(art) = placed { + if let Some(art) = placed.flatten() { self.node(s, &art, page, true); } } diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index f4a6e7f93..87b2105f3 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -149,7 +149,12 @@ impl Writer { let doc = ex.doc; let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); + // Each copy draws within an equal share of the symbol objects the export has left + // ([`vectorcraft_doc::MAX_SYMBOL_NODES`]), so every copy leaves out the same instances. + let limit = ex.symbol_limit; + let share = limit.saturating_sub(ex.symbol_nodes) / moves.len() as u64; for m in moves { + ex.symbol_limit = ex.symbol_nodes.saturating_add(share); let place = place_at(*m); let placed = place != Affine::IDENTITY; if placed { @@ -180,6 +185,7 @@ impl Writer { s.pop(); } } + ex.symbol_limit = limit; if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 8c154601b..c29a63722 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -301,6 +301,56 @@ fn each_copy_prints_with_its_marks() { assert_eq!(marks, 8 * copies, "8 trim marks around each copy"); } +#[test] +fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { + // A symbol of three squares (4 objects with its group), placed twice. + let mut d = doc(100.0, 100.0, &[]); + let squares = (0..3) + .map(|i| { + let r = Rect::new(f64::from(i) * 10.0, 0.0, f64::from(i) * 10.0 + 5.0, 5.0); + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0))) + }) + .collect(); + let art = Node::group(d.alloc_id(), squares); + d.symbols.push(vectorcraft_doc::Symbol { name: "Squares".into(), art: Arc::new(art) }); + let layer = d.default_layer().unwrap(); + for y in [10.0, 50.0] { + let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) }; + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, instance)).unwrap(); + } + let set = PdfSettings { compression: CompressionSettings { compress_text: false, ..Default::default() }, ..Default::default() }; + let mut w = crate::export::Writer::new(&d, &set, "Copies", None, false).unwrap(); + let mut ex = crate::export::Exporter::new(&d, &set); + // Room for 10 symbol objects: the first copy could write both instances (8) and leave the + // second copy none. + ex.symbol_limit = 10; + let copies = [vectorcraft_geom::Vec2::ZERO, vectorcraft_geom::Vec2::new(200.0, 0.0)]; + let page = Rect::new(0.0, 0.0, 300.0, 100.0); + let sheet = crate::export::Sheet { + size: (300.0, 100.0), + unit: 1.0, + trim: page, + bleed: page, + view: vectorcraft_geom::Affine::IDENTITY, + window: None, + place: vectorcraft_geom::Affine::IDENTITY, + copies: &copies, + area: Rect::new(0.0, 0.0, 100.0, 100.0), + clip: false, + marks: None, + negative: false, + links: false, + }; + w.page(&mut ex, &sheet).unwrap(); + assert_eq!(ex.symbol_limit, 10, "the limit is the export's again after the sheet"); + w.absorb(ex); + let (bytes, _) = w.finish(&mut std::collections::HashSet::new(), false).unwrap(); + let p = pages(&bytes); + let left = p[0].1.iter().filter(|(r, _)| r.x0 < 200.0).count(); + assert_eq!((left, p[0].1.len() - left), (3, 3), "one instance in each copy: {:?}", p[0].1); +} + #[test] fn tiles_cover_the_artboard_with_overlap() { // 1000 × 700 on Letter tiles (612 × 792), 36 pt overlap: 2 × 1. From 134520402bc3f9ecbd18cdb36ccf6abc5568b2de Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 16:56:36 -0700 Subject: [PATCH 14/23] PDF/print: import Vec2 in the export writer after merging main --- crates/pdf/src/export/writer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 87b2105f3..d7e652f2e 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -2,7 +2,7 @@ use super::entry::RgbInCmyk; use super::exporter::{constant_mask, cover, to_path, writable, xf}; use super::{ Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect, - Shape, Size, Standard, find, krilla, rfind, write, + Shape, Size, Standard, Vec2, find, krilla, rfind, write, }; /// A PDF being written, shared by export and print: the file's configuration and metadata, then From 359cd3a4b8d8de4b2f26d8ffe1c8b38635ba9f9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:29:11 -0800 Subject: [PATCH 15/23] Print: set the symbol's dynamic flag in the copies test after merging main --- crates/pdf/src/tests/print.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index c29a63722..694430ad6 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -312,7 +312,7 @@ fn every_copy_on_a_sheet_draws_the_same_symbol_instances() { }) .collect(); let art = Node::group(d.alloc_id(), squares); - d.symbols.push(vectorcraft_doc::Symbol { name: "Squares".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Squares".into(), art: Arc::new(art) }); let layer = d.default_layer().unwrap(); for y in [10.0, 50.0] { let instance = NodeKind::SymbolInstance { symbol: "Squares".into(), xf: vectorcraft_geom::Affine::translate((10.0, y)) }; From 62fd5964da1bab20799a27019ebb98eef9f16a8d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:56:15 -0800 Subject: [PATCH 16/23] i18n: drop the Gutter: rows main now carries in ru and pt-br --- crates/ui-egui/src/i18n/pt-br.tsv | 1 - crates/ui-egui/src/i18n/ru.tsv | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ae7303f24..7ea3251a1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2917,7 +2917,6 @@ digits Malayalam Malaiala Tile Type: Tipo de Bloco: Tiles are numbered across, then down; leave the range empty for every tile. Os blocos são numerados horizontalmente, depois para baixo; deixe o intervalo vazio para cada bloco. Copies per Sheet (as many as fit, in a grid) Cópias por Folha (quantas couberem, em grade) - Gutter: Medianiz: Title Case Tipo de Título Toggle Fill/Stroke Focus Alternar Foco de Preenchimento/Traçado Toggle Selection Alternar Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b4b6cad19..46c87d337 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3579,7 +3579,6 @@ digits Malayalam Малаялам tile {number} плитка {number} Tiles are numbered across, then down; leave the range empty for every tile. Плитки нумеруются слева направо, затем вниз; оставьте диапазон пустым для всех плиток. Copies per Sheet (as many as fit, in a grid) Копий на лист (сколько поместится, сеткой) - Gutter: Промежуток: Tint Оттенок Tip extends past the end point Кончик выходит за конечную точку Tip on the end point Кончик на конечной точке From 9f2fb7a9155104b7d0825bb19add59e0b9e97caf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 08:56:15 -0800 Subject: [PATCH 17/23] Print: test that print presets keep Copies per Sheet and older presets leave it off --- crates/engine/src/tests/print.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index a0beb2662..8ccd76deb 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -179,6 +179,33 @@ fn copies_per_sheet_print_a_grid_of_the_artboard() { assert_eq!(grid, one + 8); } +#[test] +fn print_presets_keep_copies_per_sheet_and_older_presets_leave_it_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 144, "height": 252})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 144, "height": 252})).unwrap(); + // [Default] prints one copy per sheet. + let v = s.execute("print.presets.list", &json!({})).unwrap(); + assert_eq!((v["presets"][0]["settings"]["copiesPerSheet"].clone(), v["presets"][0]["settings"]["gutter"].clone()), (json!(false), json!(0.0))); + // A saved preset keeps the option and the gutter, lists them as changed, and prints with them. + s.execute("print.presets.save", &json!({"name": "Cards", "settings": {"copiesPerSheet": true, "gutter": 18}})).unwrap(); + let v = s.execute("print.presets.list", &json!({})).unwrap(); + let changed: Vec<&str> = v["presets"][1]["changed"].as_array().unwrap().iter().filter_map(|c| c["option"].as_str()).collect(); + assert_eq!(changed, ["copiesPerSheet", "gutter"]); + let v = s.execute("print.preview", &json!({"preset": "Cards"})).unwrap(); + assert_eq!(v["sheets"][0]["copies"].as_array().map(Vec::len), Some(9)); + // The option survives export and import. + let text = s.execute("print.presets.export", &json!({"names": ["Cards"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("print.presets.import", &json!({"data": text})).unwrap(); + assert_eq!((t.prefs.print_presets[0].settings.copies_per_sheet, t.prefs.print_presets[0].settings.gutter), (true, 18.0)); + // A presets file written without the option imports with it off and no gutter. + let old = r#"{"format": "vcprintpresets", "presets": [{"name": "Old", "settings": {"copies": 2}}]}"#; + t.execute("print.presets.import", &json!({"data": old})).unwrap(); + let q = &t.prefs.print_presets[1].settings; + assert_eq!((q.copies, q.copies_per_sheet, q.gutter), (2, false, 0.0)); +} + #[test] fn bad_settings_are_bad_params() { let (mut s, _) = session(); From 000daef5a6eb27dea31e8d9425d322b402e9bb0c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:17:08 -0800 Subject: [PATCH 18/23] Print: the page boxes of a Copies per Sheet page hold only the copies on the paper When the placement moved some copies off the paper, each of those copies counted as the whole page, so the TrimBox and BleedBox became the paper. They now hold the copies that print, and the whole page only when no copy is on it. --- crates/pdf/src/print/layout.rs | 16 +++++++++++----- crates/pdf/src/tests/print.rs | 3 +++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 9002ceb80..3d404abab 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -246,12 +246,14 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec 0.0 { (t, b) } else { (page, page) } + (t.area() > 0.0).then_some((t, b)) }; + let boxes = |view: Affine, trim: Rect| visible(view, trim).unwrap_or((page, page)); let base = |view, window, place, area, trim, (page_trim, page_bleed), tile, copies| Layout { artboard, tile, @@ -305,8 +307,12 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec = grid.map_or_else(Vec::new, |(cols, rows)| { (0..rows).flat_map(|j| (0..cols).map(move |i| Vec2::new(i as f64 * step.0, j as f64 * step.1))).collect() }); - let page_boxes = - copies.iter().map(|m| boxes(turn, trim + *m)).reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))).unwrap_or_else(|| boxes(turn, trim)); + // The boxes hold the copies on the page; copies off it are left out. + let page_boxes = copies + .iter() + .filter_map(|m| visible(turn, trim + *m)) + .reduce(|a, b| (a.0.union(b.0), a.1.union(b.1))) + .unwrap_or_else(|| boxes(turn, trim)); pages.push(base(turn, None, place_at(trim), bleed_box, trim, page_boxes, None, copies)); continue; } diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 694430ad6..daf2bab07 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -246,6 +246,9 @@ fn copies_per_sheet_fill_the_page_in_a_grid() { // The placement places the grid, inside the margin. let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "margin": 9, "placement": {"origin": "topLeft"}}))).unwrap(); assert_eq!(corners(&pv), grid(&[9.0, 153.0, 297.0, 441.0], &[9.0, 261.0, 513.0])); + // Moved 400 pt right, two columns leave the paper: the TrimBox holds the copies still on it. + let pv = preview(&d, &settings(json!({"copiesPerSheet": true, "placement": {"origin": "topLeft", "x": 400}}))).unwrap(); + assert_eq!(pv.sheets[0].trim, [400.0, 0.0, 612.0, 756.0]); } #[test] From 81149bb945727eea8b7a0c53c0fabb5b55096061 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:04:37 -0800 Subject: [PATCH 19/23] Linux: VECTORCRAFT_TITLEBAR takes precedence over System Title Bar for one run On Linux and the BSDs, VECTORCRAFT_TITLEBAR=app now uses the app bar as the title bar with System Title Bar on. Before, System Title Bar kept the desktop's frame while the Preferences note said the variable chooses the title bar. While the variable is set, Preferences dims System Title Bar, and the note that System Title Bar takes precedence over VectorCraft Title Bar on Every Desktop does not show. macOS and Windows do not read the variable. --- apps/vectorcraft/src/main.rs | 2 +- apps/vectorcraft/src/tests.rs | 73 ++++++++++++++++++++++++ apps/vectorcraft/src/titlebar.rs | 21 +++++-- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/prefs_dialog.rs | 9 +-- crates/ui-egui/src/prefs_dialog/tests.rs | 41 ++++++++++++- docs/control-protocol.md | 4 +- docs/development.md | 2 +- docs/target-app-parity.md | 2 +- 9 files changed, 140 insertions(+), 18 deletions(-) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index b3a1fd06b..fd062e504 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -97,7 +97,7 @@ use titlebar::opted_into_system_title_bar; #[cfg(windows)] use titlebar::set_taskbar_icon; #[cfg(all(test, feature = "wgpu"))] -use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame}; +use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame, system_title_bar_applies}; use titlebar::{custom_titlebar, native_options, titlebar_from_env}; struct App { diff --git a/apps/vectorcraft/src/tests.rs b/apps/vectorcraft/src/tests.rs index 555d829e0..e81dd7815 100644 --- a/apps/vectorcraft/src/tests.rs +++ b/apps/vectorcraft/src/tests.rs @@ -245,6 +245,79 @@ fn the_start_frame_applies_the_app_title_bar_preference() { assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference"); } +/// `VECTORCRAFT_TITLEBAR` chooses the frame for the run it is set in, with System Title Bar off and +/// on. The saved preference still decides while the variable is unset. +#[test] +fn the_titlebar_variable_takes_precedence_over_system_title_bar() { + use craft_window::Frame; + assert!(super::system_title_bar_applies(true, false)); + assert!(!super::system_title_bar_applies(true, true)); + assert!(!super::system_title_bar_applies(false, false)); + assert!(!super::system_title_bar_applies(false, true)); + // VECTORCRAFT_TITLEBAR=app with System Title Bar on: VectorCraft draws its own title bar. + let frame = super::start_frame(Frame::Desktop, Some(true), false, false); + assert_eq!(super::custom_titlebar_from(&frame, super::system_title_bar_applies(true, true)), !cfg!(target_os = "macos")); + // VECTORCRAFT_TITLEBAR=desktop with System Title Bar off keeps the desktop's frame. + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + let frame = super::start_frame(Frame::App(craft_window::Layout::default()), Some(false), false, false); + assert!(!super::custom_titlebar_from(&frame, super::system_title_bar_applies(false, true))); + } +} + +/// `VECTORCRAFT_TITLEBAR` read from the environment at startup, with System Title Bar off and on. +/// The variable is process-wide and read once, so each value runs in a separate test process. +#[test] +fn the_titlebar_variable_decides_the_frame_before_the_window_opens() { + const CHILD: &str = "VECTORCRAFT_TEST_TITLEBAR_CHILD"; + if std::env::var_os(CHILD).is_none() { + for value in ["app", "system", "desktop", "unknown"] { + // Linux and the BSDs: hold `spawning` while the child starts, so it does not keep a + // copy of a GPU start test's locked start file (see `CHILD_SPAWN`). + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + let spawning = super::spawning(); + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::the_titlebar_variable_decides_the_frame_before_the_window_opens", "--nocapture"]) + .env(CHILD, "1") + .env("VECTORCRAFT_TITLEBAR", value) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + drop(spawning); + let output = child.unwrap().wait_with_output().unwrap(); + assert!( + output.status.success(), + "VECTORCRAFT_TITLEBAR={value}\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + return; + } + let value = std::env::var("VECTORCRAFT_TITLEBAR").unwrap(); + let not_read = cfg!(any(target_os = "macos", target_os = "windows")); + assert_eq!(super::titlebar_from_env(), !not_read && value != "unknown", "VECTORCRAFT_TITLEBAR={value}"); + let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); + let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); + for (saved, opted_into_system) in [(None, false), (Some(&off), false), (Some(&on), true)] { + let custom = super::custom_titlebar(saved); + let expected = match value.as_str() { + _ if not_read => !cfg!(target_os = "macos") && !opted_into_system, + "app" => true, + "system" | "desktop" => false, + // Not a value the variable takes: System Title Bar decides when it is on. Otherwise the + // `craft-window` probe decides, the same with System Title Bar off as with no saved + // preferences. + _ if opted_into_system => false, + _ => super::custom_titlebar(None), + }; + assert_eq!(custom, expected, "{value}, preference {opted_into_system}"); + assert_eq!(super::native_options(custom).viewport.decorations, Some(!custom), "{value}, preference {opted_into_system}"); + } +} + /// The file extensions the macOS bundle declares: its document types and its own exported type. fn plist_extensions(plist: &str) -> Vec<&str> { ["CFBundleTypeExtensions", "public.filename-extension"] diff --git a/apps/vectorcraft/src/titlebar.rs b/apps/vectorcraft/src/titlebar.rs index 6e7451ed9..17c847040 100644 --- a/apps/vectorcraft/src/titlebar.rs +++ b/apps/vectorcraft/src/titlebar.rs @@ -41,7 +41,8 @@ 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. +/// `false` (the default). This preference forces the desktop/native frame back on unless +/// `VECTORCRAFT_TITLEBAR` is set ([`system_title_bar_applies`]). 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) } @@ -87,12 +88,18 @@ fn probed_choice() -> &'static craft_window::Choice { } /// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › -/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always -/// `false` on macOS and Windows. +/// User Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while +/// it is set. Always `false` on macOS and Windows. pub(crate) fn titlebar_from_env() -> bool { cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some() } +/// Whether System Title Bar decides this start: `opted_into_system` unless `VECTORCRAFT_TITLEBAR` +/// is set (`from_env`), which chooses the frame for the run it is set in. +pub(crate) fn system_title_bar_applies(opted_into_system: bool, from_env: bool) -> bool { + opted_into_system && !from_env +} + /// 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. /// @@ -102,7 +109,8 @@ pub(crate) fn titlebar_from_env() -> bool { /// ([`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. +/// title bar; the preference forces the native frame back on unless `VECTORCRAFT_TITLEBAR` is set +/// ([`system_title_bar_applies`]). /// /// 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 @@ -124,7 +132,8 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// 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`]. +/// Title Bar preference, which does not apply while the variable is set. 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 @@ -133,7 +142,7 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst /// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]). pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { let choice = probed_choice(); - let opted_into_system = opted_into_system_title_bar(saved); + let opted_into_system = system_title_bar_applies(opted_into_system_title_bar(saved), titlebar_from_env()); // macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from` // ignores the frame there). let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b931aaef..802bb695d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -196,8 +196,8 @@ pub struct VectorcraftApp { /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, /// `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences › User - /// Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. `false` - /// unless the host sets it. + /// Interface › System Title Bar and VectorCraft Title Bar on Every Desktop do not apply while + /// it is set. `false` unless the host sets it. pub titlebar_from_env: bool, /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 733aaf200..d5f391433 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -36,8 +36,8 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); - // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (VectorCraft Title Bar on - // Every Desktop's note). + // Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed + // row and VectorCraft Title Bar on Every Desktop's note). fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env)); fields.insert("__search".into(), json!("")); fields.insert("__focusSearch".into(), json!(true)); @@ -483,7 +483,8 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // Windows and Linux only: macOS always has the system's title bar, the web has none. PrefKind::Bool if sp.key == "systemTitleBar" => { if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) { - bool_row(ui, d, sp.key, sp.label); + // VECTORCRAFT_TITLEBAR chooses the frame while it is set: the checkbox is dimmed. + ui.add_enabled_ui(!d.bool("__titlebarFromEnv"), |ui| bool_row(ui, d, sp.key, sp.label)); // The window's decorations are chosen once, when the app starts. On Linux and the // BSDs the note goes under the next row, which also applies at the next start. if !APP_TITLE_BAR_SHOWN { @@ -499,7 +500,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let from_env = d.bool("__titlebarFromEnv"); let system = d.bool("systemTitleBar"); ui.add_enabled_ui(!from_env && !system, |ui| bool_row(ui, d, sp.key, sp.label)); - if system { + if system && !from_env { let note = tl!("System Title Bar takes precedence over this option while it is on."); ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0)); } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 11fd0f418..9ef8f3ed7 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -336,7 +336,8 @@ fn app_title_bar_preference_shows_and_persists() { /// VectorCraft Title Bar on Every Desktop does not take clicks while System Title Bar is on or /// VECTORCRAFT_TITLEBAR is set. With the variable set its note names the variable in place of the -/// next-launch note, and with System Title Bar on a note says that System Title Bar takes precedence. +/// next-launch note, and with System Title Bar on and the variable unset a note says that System +/// Title Bar takes precedence. #[test] fn app_title_bar_is_dimmed_while_something_takes_precedence() { fn texts(s: &egui::Shape, out: &mut Vec) { @@ -364,7 +365,7 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() { let mut shown = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); assert_eq!(shown.iter().any(|t| t == note), supported && from_env, "{shown:?}"); - assert_eq!(shown.iter().any(|t| t == system_note), supported && system, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == system_note), supported && system && !from_env, "{shown:?}"); // The next-launch note stays under this row on Linux and the BSDs, where it also covers // System Title Bar, and under System Title Bar on Windows. let restart_shown = if supported { !from_env } else { cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) }; @@ -392,6 +393,42 @@ fn app_title_bar_is_dimmed_while_something_takes_precedence() { } } +/// While VECTORCRAFT_TITLEBAR chooses the frame, System Title Bar does not take clicks. +#[test] +fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame() { + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + for from_env in [false, true] { + let mut a = app(); + a.titlebar_from_env = from_env; + open(&mut a, Some("User Interface")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let label = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "System Title Bar" => Some(t.visual_bounding_rect()), + _ => None, + }); + assert_eq!(label.is_some(), supported); + if let Some(r) = label { + for pressed in [true, false] { + let input = egui::RawInput { + events: vec![ + egui::Event::PointerMoved(r.center()), + egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + } + assert_eq!(a.ui.dialog.as_ref().unwrap().bool("systemTitleBar"), !from_env, "clicked with the variable {from_env}"); + } + } +} + /// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and /// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop /// app reads before the window opens. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0e6611a85..4e362d17b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -336,7 +336,9 @@ when the app starts, off by default) turns off the desktop's frame and uses the sessions where the desktop would draw the frame: X11 sessions, Wayland compositors that draw server-side decorations such as KDE Plasma, and a window opened through XWayland. System Title Bar and `VECTORCRAFT_TITLEBAR` (`app`, or `system` or `desktop`) take precedence over it. The checkbox is dimmed while either applies, and a note under it names -each one that applies. +each one that applies. `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in: +`app` keeps the app bar as the title bar with System Title Bar on. While the variable is set, the Preferences dialog dims +System Title Bar and its `__titlebarFromEnv` field is `true`. Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer); diff --git a/docs/development.md b/docs/development.md index 3204c445b..a4077abc6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -118,7 +118,7 @@ A file opened from the file manager (double-click, Open With, a drop on the exec ## Linux: Wayland and X11 -On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over VectorCraft Title Bar on Every Desktop. +On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** and restart to use winit's font-free Adwaita window controls (`wayland-csd-adwaita-notitle`); the existing custom title bar remains the Linux default. On X11, on a Wayland compositor that draws server-side decorations such as KDE Plasma, and in a window opened through XWayland, enable **Preferences › User Interface › VectorCraft Title Bar on Every Desktop** and restart to use the app bar as the title bar instead of the desktop's frame. `VECTORCRAFT_TITLEBAR=app`, `=system` or `=desktop` chooses the frame for one run and takes precedence over System Title Bar and VectorCraft Title Bar on Every Desktop. The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f95308865..eb4e964a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -819,7 +819,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). - Edit › Keyboard Shortcuts › Export Text… saves the set shown in the dialog as a UTF-8 text file, as the reference app's [Export keyboard shortcuts list as a text document](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/export-keyboard-shortcuts-list.html) page describes (`shortcuts.exportText {path?}`). The layout is VectorCraft's own: the set's name, then the Tools and Menu Commands rows in the dialog's order, each with its name, a tab and its shortcuts as the dialog shows them, and nothing after the tab for a row with no shortcut. Tests `shortcut_editor::tests::export_text_*` and `tests::picks::keyboard_shortcuts_export_text_writes_the_working_set_as_text`. - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it. + - On Linux and the BSDs, Preferences › User Interface › VectorCraft Title Bar on Every Desktop (off by default) uses the app bar as the title bar from the next start in the sessions where the desktop would draw the window's frame (X11, Wayland compositors with server-side decorations such as KDE Plasma, a window opened through XWayland). System Title Bar and `VECTORCRAFT_TITLEBAR` take precedence over it, and `VECTORCRAFT_TITLEBAR` also takes precedence over System Title Bar for the run it is set in. - Edit › Keyboard Shortcuts lists items of the menu bar and panel menus, as the reference app's [Customize keyboard shortcuts](https://web.archive.org/web/20221205131209id_/https://helpx.adobe.com/illustrator/using/customizing-keyboard-shortcuts.html) page describes for its Menu Commands. A menu command with no default shortcut that needs a value a key press does not give, such as File › Document Setup › Units (`document.setUnits {units}`), is listed only while the user has changed its shortcut. A command stays listed when a menu bar item runs it without a value or when it opens a dialog run without one, as the Variables panel's commands do. - A shortcut on the key left of 1 runs whatever character that key types, and Edit › Keyboard Shortcuts, the menus in the window, the command palette and tool tips show it as that character once the key has been pressed: `"` on a Turkish Q layout, `~` on a US layout as before. Shortcuts saved as `~` keep working (#4559). - On Windows a maximized window stays inside the work area with the taskbar on any edge (#3242, #3723). From 6b2559b63ecf762033a0f2b60a8a5efa3df839b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:33:23 -0800 Subject: [PATCH 20/23] Docs: a delete of the last setting removes the settings file --- docs/plugins.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 00725a887..9d55df814 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -276,8 +276,10 @@ doesn't fit and nothing is copied. A call that would pass the 1 GiB returns `-7` The app keeps the settings in memory after the first call and reads `settings.json` again only when the file's size or modified time differs from the last read or write. A get answered from memory reads no file and counts no bytes. A -set to the value the key already has writes nothing, and neither does a delete of a key that has no value. Every -other successful set or delete writes the file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and the run waits for it. +set to the value the key already has writes nothing, and neither does a delete of a key that has no value. A delete +of the last setting removes the file and counts no written bytes. Every other successful set or delete writes the +file before the call returns. A cache call reads or writes the whole entry in memory in one step, up to 64 MiB, and +the run waits for it. A live effect runs while the art draws, so keep the entries it uses small. - A key is 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, and is not a Windows device name (`CON`, `PRN`, From 124cbb55fa292036d11f1985191c51e045fa46df Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:33:23 -0800 Subject: [PATCH 21/23] Docs: the permissions line says a grant applies to a plug-in that lists permissions --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 579866738..03876bf7a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1566,7 +1566,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. -- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. From 3734368ec72f0aaeec51ffca349796deac2315a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:33:48 -0800 Subject: [PATCH 22/23] Updates: craft-update comes from storytold/craft-libs The pin stays at the head of storytold/craft-libs#696. The commit is reachable from storytold, so the dependency no longer names the fork. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a22c489f5..0cc81b6e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/dotCooCoo/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" +source = "git+https://github.com/storytold/craft-libs?rev=c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e#c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 8a1ee4ab4..5b11c8623 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63 # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/dotCooCoo/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c365d75c48b0e47f4b43cdb6592d0cdc39d2d52e", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From 6e8e1e213ee3a8c081d2616683223aef135c9a00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:47:54 -0800 Subject: [PATCH 23/23] Type: a pushed-in line leaves the gap after inline art open Kinsoku push-in room does not count a gap after inline art or inside a list marker, but the squeeze also closed those gaps. The squeeze now applies only to the gaps the room counts. --- crates/text/src/layout/flow.rs | 19 ++++++++++++++++--- crates/text/src/tests/inline.rs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 83aacda3c..652213940 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -607,6 +607,17 @@ fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option], index: usiz }) } +// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, +// which the push-in's room does not count either. +fn may_squeeze(g: &SGlyph) -> bool { + g.len > 0 && g.inline.is_none() +} + +// How much a pushed-in line's `squeeze` adds to the CJK gap after `g`. +fn squeeze_after(g: &SGlyph, squeeze: f64, vertical: bool) -> f64 { + if may_squeeze(g) { squeeze * squeeze_em(g, vertical) } else { 0.0 } +} + // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph // scaling of a `hung` one. fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) -> Flex { @@ -672,8 +683,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin // the same share of the em of the glyph before it, at most [`PUSH_IN_SQUEEZE`]. let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { - let ems: f64 = - (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).map(|j| squeeze_em(&sg[j], cx.vertical)).sum(); + let ems: f64 = (line.index..line.body) + .filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .map(|j| squeeze_em(&sg[j], cx.vertical)) + .sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } @@ -778,7 +791,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData advance += justification.per_space; } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + justification.squeeze * squeeze_em(glyph, cx.vertical); + advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); } } else if !line.held.contains(&glyph_index) && drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy()) diff --git a/crates/text/src/tests/inline.rs b/crates/text/src/tests/inline.rs index d492608cc..76c90e65b 100644 --- a/crates/text/src/tests/inline.rs +++ b/crates/text/src/tests/inline.rs @@ -347,6 +347,25 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() { assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); } +/// A pushed-in line closes the gaps next to its CJK characters and leaves the gap after inline art +/// open. Needs a font with full-width Japanese (skipped without one). +#[test] +fn a_pushed_in_line_leaves_the_gap_after_inline_art() { + let mut t = TextObject::point(Point::ZERO, "一二", style(20.0)); + t.xf = Affine::IDENTITY; + t.runs.push(TextRun::inline(art(0.5), style(20.0))); + t.runs.push(TextRun::new("四五六七八九十一二三四五」六", style(20.0))); + t.para.kinsoku_type = vectorcraft_doc::KinsokuShoriType::PushInFirst; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 200.0).to_path(0.1)) }; + let l = layout(FontDb::global(), &t); + if (l.glyphs[0].advance - 20.0).abs() > 2.0 { + return; // no font with full-width Japanese here + } + assert!(l.glyphs[1].advance < 20.0 - 0.5, "the other gaps close up: {}", l.glyphs[1].advance); + let art = inline_glyph(&l); + assert!((art.advance - 20.0).abs() < 1e-6, "the art keeps its advance: {}", art.advance); +} + #[test] fn inline_art_moves_with_vertically_aligned_lines() { use vectorcraft_doc::text::{AreaOptions, VerticalAlign};