From 90788b632afd76f9e3232346f0a62f8b0928a3cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 09:50:51 -0700 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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 31769bd8addea49553e6fc17d654a706ba87be5c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 11:53:40 -0700 Subject: [PATCH 23/33] =?UTF-8?q?Part=20of=20#978:=20Window=20=E2=80=BA=20?= =?UTF-8?q?Color=20Wheel=20picks=20colors=20on=20a=20hue=20ring=20and=20a?= =?UTF-8?q?=20saturation=20and=20brightness=20square?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Color Wheel opens a new panel: the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up at the ring's hue. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. The Color panel does not change. A pick is display sRGB and goes into the working RGB space through the color settings. An RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. The wheel keeps the hue of the last pick while the objects, the proxy and the color are unchanged, so a drag through a gray and back keeps the hue. A press gives the ring or the square the keyboard focus. The arrow keys then step the hue by 1° or the saturation and brightness by 1%. With Shift a press steps ten times as far, and with Cmd/Ctrl it steps a tenth as far. Each press makes one undo step, and Escape or a click elsewhere gives the keys back to the tools. Screen readers read the ring as a slider from 0 to 360 with increment, decrement and set-value actions, and the square as its name and both values. The Color Picker's color field is the same widget, so it takes the same keys and reports its name and values. Color::to_hsb and Color::from_hsb now call the new rgb_to_hsb and hsb_to_rgb, which the panel uses on display sRGB numbers. --- crates/color/src/lib.rs | 72 +-- crates/ui-egui/src/dialogs/color_picker.rs | 21 +- crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/panels.rs | 4 + crates/ui-egui/src/panels/color.rs | 15 +- crates/ui-egui/src/panels/color_wheel.rs | 151 +++++++ .../ui-egui/src/panels/tests_color_wheel.rs | 423 ++++++++++++++++++ crates/ui-egui/src/state.rs | 1 + crates/ui-egui/src/widgets.rs | 8 +- crates/ui-egui/src/widgets/color.rs | 244 +++++++++- crates/ui-egui/src/widgets/numeric.rs | 2 +- docs/control-protocol.md | 5 + docs/target-app-parity.md | 1 + 28 files changed, 966 insertions(+), 53 deletions(-) create mode 100644 crates/ui-egui/src/panels/color_wheel.rs create mode 100644 crates/ui-egui/src/panels/tests_color_wheel.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5de064d6b..03e7dc10e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -143,38 +143,11 @@ impl Color { } /// HSB with hue in degrees 0..360, saturation and brightness 0..1. pub fn to_hsb(&self) -> [f32; 3] { - let [r, g, b] = self.to_rgb(); - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let d = max - min; - let h = if d <= 0.0 { - 0.0 - } else if max == r { - 60.0 * (((g - b) / d).rem_euclid(6.0)) - } else if max == g { - 60.0 * ((b - r) / d + 2.0) - } else { - 60.0 * ((r - g) / d + 4.0) - }; - // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. - let h = if h >= 360.0 { 0.0 } else { h }; - let s = if max <= 0.0 { 0.0 } else { d / max }; - [h, s, max] + rgb_to_hsb(self.to_rgb()) } pub fn from_hsb(h: f32, s: f32, v: f32) -> Self { - let h = h.rem_euclid(360.0) / 60.0; - let c = v * s; - let x = c * (1.0 - (h % 2.0 - 1.0).abs()); - let (r, g, b) = match h as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - Color::Rgb { r: r + m, g: g + m, b: b + m } + let [r, g, b] = hsb_to_rgb([h, s, v]); + Color::Rgb { r, g, b } } /// `#rrggbb` (display RGB). pub fn to_hex(&self) -> String { @@ -327,6 +300,45 @@ impl Color { } } +/// HSB (hue in degrees 0..360, saturation and brightness 0..1) of RGB numbers 0..1, with no color +/// conversion ([`Color::to_hsb`] reads the display color first). +pub fn rgb_to_hsb([r, g, b]: [f32; 3]) -> [f32; 3] { + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + let h = if d <= 0.0 { + 0.0 + } else if max == r { + 60.0 * (((g - b) / d).rem_euclid(6.0)) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. + let h = if h >= 360.0 { 0.0 } else { h }; + let s = if max <= 0.0 { 0.0 } else { d / max }; + [h, s, max] +} + +/// RGB numbers 0..1 of HSB `[h, s, v]` (hue in degrees, saturation and brightness 0..1): the inverse +/// of [`rgb_to_hsb`]. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m] +} + /// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations /// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from. pub fn keep_model(orig: Color, new: Color) -> Color { diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index fdbf335cb..73d9a532d 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -115,6 +115,23 @@ impl Channel { Channel::Blue => (2, 0, 1), } } + /// The color field's axes (across, up) as keys and screen readers see them: hue in degrees, + /// saturation and brightness in percent, red, green and blue from 0 to 255. + fn field_axes(self) -> [widgets::FieldAxis<'static>; 2] { + let axis = |i: usize| { + let (name, units, suffix) = match (self.is_hsb(), i) { + (true, 0) => (tl!("Hue"), 360.0, "°"), + (true, 1) => (tl!("Saturation"), 100.0, "%"), + (true, _) => (tl!("Brightness"), 100.0, "%"), + (false, 0) => (tl!("Red"), 255.0, ""), + (false, 1) => (tl!("Green"), 255.0, ""), + (false, _) => (tl!("Blue"), 255.0, ""), + }; + widgets::FieldAxis { name, units, suffix } + }; + let (_, x, y) = self.axes(); + [axis(x), axis(y)] + } } /// The picked colour with its HSB (hue in degrees), kept so the hue survives grey colours. @@ -367,7 +384,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { } else { let (z, x, y) = field_pos(channel, &p); let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); - if let (Some((x, y)), _) = widgets::color_field(ui, "cp-field", vec2(side, side), (x, y), &at) { + if let (Some((x, y)), _) = + widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), Some((x, y)), &at) + { pick = Some(field_pick(channel, z, x, y)); } ui.add_space(SLIDER_GAP); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5b283d741..b7afc694a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -705,6 +705,11 @@ Color Barva Color Guide Průvodce barvami Color Themes Barevné motivy + Color Wheel Barevné kolo + Hue ring Prstenec odstínů + Saturation and brightness Sytost a jas + Color field Barevné pole + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Vlastnosti CSS Document Info Informace o dokumentu Flattener Preview Náhled sloučení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 21e806ebc..956b4813f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -544,6 +544,11 @@ font-technology Variable Variabel Color Table ({n} colors) Farbtabelle ({n} Farben) Color Theme Farbthema Color Themes Farbthemen + Color Wheel Farbkreis + Hue ring Farbtonring + Saturation and brightness Sättigung und Helligkeit + Color field Farbauswahlfeld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Farbart: Color: Farbe: Colorize Einfärben diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e90cf58f3..cb4ce9159 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -566,6 +566,11 @@ font-technology Variable Variables Color Table ({n} colors) Tabla de colores ({n} colores) Color Theme Tema de color Color Themes Temas de color + Color Wheel Rueda de color + Hue ring Anillo de tono + Saturation and brightness Saturación y brillo + Color field Campo de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de color: Color: Color: Colorize Colorear diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 02235699b..f3dbb8795 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -567,6 +567,11 @@ font-technology Variable Variables Color Table ({n} colors) Table des couleurs ({n} couleurs) Color Theme Thème de couleurs Color Themes Thèmes de couleurs + Color Wheel Roue chromatique + Hue ring Anneau de teinte + Saturation and brightness Saturation et luminosité + Color field Champ de couleur + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Type de couleur : Color: Couleur : Colorize Coloriser diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bcb46aa3c..a39b4d099 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -742,6 +742,11 @@ font-technology Variable Változó Color Table ({n} colors) Színtábla ({n} szín) Color Theme Színtéma Color Themes Színtémák + Color Wheel Színkör + Hue ring Színárnyalat-gyűrű + Saturation and brightness Telítettség és fényesség + Color field Színmező + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Színtípus: Color: Szín: Colorization Színezés diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0c3b98b89..c948d57aa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -566,6 +566,11 @@ font-technology Variable Variabili Color Table ({n} colors) Tabella colori ({n} colori) Color Theme Tema colore Color Themes Temi colore + Color Wheel Ruota dei colori + Hue ring Anello tonalità + Saturation and brightness Saturazione e luminosità + Color field Campo colore + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo di colore: Color: Colore: Colorize Colora diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b7beb7356..2e79af180 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -714,6 +714,11 @@ digits Malayalam マラヤーラム数字 Color カラー Color Guide カラーガイド Color Themes カラーテーマ + Color Wheel カラーホイール + Hue ring 色相リング + Saturation and brightness 彩度と明るさ + Color field カラーフィールド + {name1} {value1}, {name2} {value2} {name1} {value1}、{name2} {value2} CSS Properties CSS プロパティ Document Info ドキュメント情報 Flattener Preview 分割・統合プレビュー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 36cbe846e..30d145b3d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -707,6 +707,11 @@ digits Malayalam 말라얄람어 Color 색상 Color Guide 색상 가이드 Color Themes 색상 테마 + Color Wheel 색상환 + Hue ring 색조 링 + Saturation and brightness 채도 및 밝기 + Color field 색상 필드 + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS 속성 Document Info 문서 정보 Flattener Preview 병합 미리 보기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e9e25a7f7..15f4382fd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -654,6 +654,11 @@ Color Kolor Color Guide Przewodnik kolorów Color Themes Motywy kolorów + Color Wheel Koło kolorów + Hue ring Pierścień barwy + Saturation and brightness Nasycenie i jasność + Color field Pole koloru + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Właściwości CSS Document Info Informacje o dokumencie Flattener Preview Podgląd spłaszczania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fde3c95b6..b579ae093 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -511,6 +511,7 @@ antiAlias Sharp Nítido Color Swatches Amostras de Cor Color Table Tabela de Cores Color Themes Temas de Cores + Color Wheel Roda de Cores Color Type: Tipo de Cor: Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia Color blindness – Protanopia-type Daltonismo – tipo protanopia diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 507e663a7..984c74f96 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -564,6 +564,11 @@ font-technology Variable Вариативные Color Table ({n} colors) Цветовая таблица ({n} цветов) Color Theme Цветовая тема Color Themes Цветовые темы + Color Wheel Цветовой круг + Hue ring Кольцо оттенков + Saturation and brightness Насыщенность и яркость + Color field Цветовое поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип цвета: Color: Цвет: Colorize Тонировать diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e0089d18e..1f4e6a72c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -560,6 +560,11 @@ font-technology Variable Варіативні Color Table ({n} colors) Таблиця кольорів ({n} кольорів) Color Theme Колірна тема Color Themes Колірні теми + Color Wheel Колірне коло + Hue ring Кільце відтінків + Saturation and brightness Насиченість і яскравість + Color field Колірне поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Тип кольору: Color: Колір: Colorize Тонувати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b1ce3d82c..4855ead0e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -653,6 +653,11 @@ font-technology Variable Biến thiên Color Table ({n} colors) Bảng màu ({n} màu) Color Theme Bộ màu Color Themes Bộ màu + Color Wheel Bánh xe màu + Hue ring Vòng sắc độ + Saturation and brightness Độ bão hòa và độ sáng + Color field Vùng chọn màu + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Loại màu: Color: Màu: Colorization Tô màu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 32781e7ef..e88ed56de 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -512,6 +512,11 @@ antiAlias Sharp 锐利 Color Table ({n} colors) 颜色表 ({n} 色) Color Theme 色彩主题 Color Themes 颜色主题 + Color Wheel 色轮 + Hue ring 色相环 + Saturation and brightness 饱和度和亮度 + Color field 颜色区域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 颜色类型: Color: 颜色: Colorize 上色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 57aeca5f8..3e2e95108 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -528,6 +528,11 @@ font-technology Variable 可變 Color Table ({n} colors) 顏色表 ({n} 色) Color Theme 色彩主題 Color Themes 色彩主題 + Color Wheel 色輪 + Hue ring 色相環 + Saturation and brightness 飽和度和亮度 + Color field 顏色區域 + {name1} {value1}, {name2} {value2} {name1} {value1},{name2} {value2} Color Type: 顏色類型: Color: 顏色: Colorize 上色 diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index da521390a..fdea9664b 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -644,6 +644,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { panel("Color", "color"), panel("Color Guide", "colorGuide"), panel("Color Themes", "colorThemes"), + panel("Color Wheel", crate::panels::color_wheel::ID), panel("CSS Properties", crate::panels::css_properties::ID), panel("Document Info", "docInfo"), panel("Flattener Preview", crate::panels::flattener_preview::ID), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 84eaf8791..2282633eb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -15,6 +15,7 @@ pub mod character; pub mod color; pub mod color_guide; pub mod color_themes; +pub mod color_wheel; pub mod css_properties; pub mod doc_info; pub mod flattener_preview; @@ -177,6 +178,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { svg_interactivity::ID => svg_interactivity::show(app, ui), variables::ID => variables::show(app, ui), "colorThemes" => color_themes::show(app, ui), + color_wheel::ID => color_wheel::show(app, ui), links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), @@ -655,6 +657,8 @@ mod tests_blend; #[cfg(test)] mod tests_brush_libraries; #[cfg(test)] +mod tests_color_wheel; +#[cfg(test)] mod tests_constrain; #[cfg(test)] mod tests_css_properties; diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 643a7ff66..e58ca56f6 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -354,7 +354,7 @@ fn to32(c: &Color) -> Color32 { } /// The global or spot swatch a colour is a tint of (Tint mode). -struct Tint { +pub(crate) struct Tint { swatch: String, spot: bool, /// 0..1. @@ -375,20 +375,20 @@ impl Tint { /// What the panel is editing: the active proxy's solid colour or a selected gradient stop (with /// the global swatch it is a tint of), or a selected freeform point. -enum Target { +pub(crate) enum Target { Paint(Option, Option), Stop { paint: Paint, index: usize, color: Color, tint: Option }, Point { index: usize, color: Color }, } impl Target { - fn color(&self) -> Option { + pub(crate) fn color(&self) -> Option { match self { Target::Paint(c, _) => *c, Target::Stop { color, .. } | Target::Point { color, .. } => Some(*color), } } - fn tint(&self) -> Option<&Tint> { + pub(crate) fn tint(&self) -> Option<&Tint> { match self { Target::Paint(_, t) | Target::Stop { tint: t, .. } => t.as_ref(), Target::Point { .. } => None, @@ -425,9 +425,14 @@ fn target_with(app: &VectorcraftApp, mixed: bool) -> Target { } } +/// [`target`] for the Color Wheel panel, which edits what this panel's sliders edit. +pub(crate) fn wheel_target(app: &VectorcraftApp, ui: &Ui) -> Target { + target(app, ui) +} + /// Apply `c` to the target, or with `behind` (an Alt-click) to the inactive proxy as a solid /// colour, keeping the active proxy in front. -fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { +pub(crate) fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind: bool) { match tgt { Target::Stop { paint: Paint::Gradient(g), index, .. } if !behind => { let mut stops = g.gradient.stops.clone(); diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs new file mode 100644 index 000000000..6df65411d --- /dev/null +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -0,0 +1,151 @@ +//! Color Wheel panel (Window › Color Wheel): the Fill and Stroke proxy beside a hue ring around a +//! square with saturation across and brightness up at the ring's hue. The wheel edits what the +//! Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform +//! point) through the same paint commands, and a drag is one undo step. Alt paints the inactive +//! proxy and Shift steps the ring by 45°. A pick is display sRGB, converted into the working RGB +//! space: an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch +//! becomes a process color in the document's color mode. The HSB picked last stays while the +//! objects, the proxy and the color are unchanged, so a gray keeps its hue. + +use egui::{Ui, UiBuilder}; +use vectorcraft_color::cms::{self, Cms}; +use vectorcraft_color::{Color, Paint, hsb_to_rgb, rgb_to_hsb}; +use vectorcraft_doc::{ColorMode, NodeId}; + +use super::color::{Target, apply, wheel_target}; +use super::{pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::widgets::{self, FieldAxis, Live}; + +pub const ID: &str = "colorWheel"; +/// The widest the wheel is drawn. +const MAX_WHEEL: f32 = 200.0; +/// Panel state: the HSB picked last and its [`Owner`]. +const MEMORY: &str = "color-wheel-hsb"; + +/// What the HSB picked last belongs to: the document, the selected objects, the proxy, the +/// gradient stop or freeform point, and the color the pick stored. The wheel shows that HSB only +/// while all of them are unchanged. +#[derive(Clone, Debug, PartialEq)] +struct Owner { + doc: Option, + selection: Vec, + fill: bool, + /// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`). + slot: Option<(bool, usize)>, + color: Color, +} + +impl Owner { + fn of(app: &VectorcraftApp, tgt: &Target, color: Color) -> Self { + let st = app.session.active(); + let slot = match tgt { + Target::Paint(..) => None, + Target::Stop { index, .. } => Some((false, *index)), + Target::Point { index, .. } => Some((true, *index)), + }; + Self { + doc: st.map(|d| d.uid), + selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(), + fill: app.session.fill_active, + slot, + color, + } + } +} + +/// The HSB a color shows on the wheel: that of its display sRGB through `cms`. +pub(crate) fn display_hsb(cms: &Cms, c: &Color) -> [f32; 3] { + rgb_to_hsb(cms.display_rgb(c)) +} + +/// The color a pick of display HSB `hsb` stores in place of `was`, through `cms`. The picked sRGB +/// goes into the working RGB space. An RGB color stays RGB, and a CMYK or Lab color converts to its +/// model with the color settings. Anything else (no color, a Gray color, a tint of a global swatch) +/// becomes a process color in the document's color mode: managed CMYK when `cmyk_doc`, else RGB. +pub(crate) fn stored(cms: &Cms, hsb: [f32; 3], was: Option<&Color>, cmyk_doc: bool) -> Color { + let [r, g, b] = cms.srgb_to_rgb(hsb_to_rgb(hsb)); + let rgb = Color::rgb(r, g, b); + let cmyk = || { + let [c, m, y, k] = cms.to_cmyk(&rgb, cms.settings().intent); + Color::cmyk(c, m, y, k) + }; + match was { + Some(Color::Rgb { .. }) => rgb, + Some(Color::Cmyk { .. }) => cmyk(), + Some(Color::Lab { .. }) => { + let l = cms.lab(&rgb); + Color::lab(l.l, l.a, l.b) + } + _ if cmyk_doc => cmyk(), + _ => rgb, + } +} + +/// The color a pick replaces, which sets the model of the new one: the target's, or with Alt +/// (`behind`) the inactive proxy's solid color. None for a tint of a global swatch. +fn replaced(app: &VectorcraftApp, tgt: &Target, behind: bool) -> Option { + if !behind { + return tgt.color().filter(|_| tgt.tint().is_none()); + } + let (fill, stroke) = super::current_paints(app); + match if app.session.fill_active { stroke } else { fill } { + Paint::Solid { color, swatch, .. } => { + let global = swatch.is_some_and(|n| app.session.active().is_some_and(|d| d.doc.global_color(&n).is_some())); + (!global).then_some(color) + } + _ => None, + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let tgt = wheel_target(app, ui); + let color = tgt.color(); + let cms = cms::active(); + // With None, a pattern or a "?" proxy the wheel shows the last solid color without markers, + // and a click applies the picked color. + let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { + (Some(c), Some((owner, hsb))) if owner == Owner::of(app, &tgt, c) => hsb, + (c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)), + }; + let marked = color.is_some(); + let mut pick: Option<([f32; 3], Live)> = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(44.0); + super::proxy(app, ui, 40.0); + }); + let size = ui.available_width().min(MAX_WHEEL); + let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, marked.then_some(hsb[0])); + let square = widgets::ring_square(ring.rect); + let mut field_ui = ui.new_child(UiBuilder::new().id_salt("color-wheel-square").max_rect(square)); + let axes = + [FieldAxis { name: tl!("Saturation"), units: 100.0, suffix: "%" }, FieldAxis { name: tl!("Brightness"), units: 100.0, suffix: "%" }]; + let at = |x: f32, y: f32| widgets::hsb32([hsb[0], x, 1.0 - y]); + let pos = marked.then_some((hsb[1], 1.0 - hsb[2])); + let name = tl!("Saturation and brightness"); + let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), pos, &at); + if let Some((h, phase)) = ring.hue { + pick = Some(([h, hsb[1], hsb[2]], phase)); + } + // A press between the square and the ring picks the closest point of the square, which + // takes the keyboard focus. + if let Some((p, phase)) = ring.inner { + let field_id = widgets::id_with(field_ui.scope_id(), "field"); + field_ui.memory_mut(|m| m.request_focus(field_id)); + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + pick = Some(([hsb[0], f(p.x, square.left(), square.width()), 1.0 - f(p.y, square.top(), square.height())], phase)); + } + if let Some((x, y)) = field { + pick = Some(([hsb[0], x, 1.0 - y], field_phase)); + } + }); + let Some((hsb, phase)) = pick else { return }; + let behind = super::alt_held(ui); + let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk); + let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc); + if !behind { + set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), hsb))); + } + apply(app, &tgt, c, phase, behind); +} diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs new file mode 100644 index 000000000..b5498e93d --- /dev/null +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -0,0 +1,423 @@ +//! The Color Wheel panel drawn headless: Window › Color Wheel, picks on the hue ring and the +//! square, Shift and Alt, gradient stops, None, the remembered hue, the color model of a pick, the +//! arrow keys and what screen readers read. + +use egui::accesskit::{Action, ActionData, ActionRequest}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; +use vectorcraft_color::cms::{self, Cms, ColorSettings, delta_e2000}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_engine::Session; + +use super::color_wheel::{ID, display_hsb, show, stored}; +use crate::VectorcraftApp; + +/// An app with a selected rectangle filled with `fill`. +fn app(fill: serde_json::Value) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": fill})).unwrap(); + app +} + +/// The panel in a 260-point-wide flyout, with the app's shortcuts after it as in a frame of the app. +fn harness(app: VectorcraftApp) -> Harness<'static, VectorcraftApp> { + let mut h = Harness::builder().with_size(vec2(260.0, 320.0)).build_ui_state( + |ui, app: &mut VectorcraftApp| { + show(app, ui); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(2); + h +} + +fn rect_of(h: &Harness<'static, VectorcraftApp>, label: &str) -> Rect { + h.get_by_label(label).rect() +} + +/// The point at hue `deg` on the ring drawn in `ring`, `out` points outside the middle of its band. +fn on_ring(ring: Rect, deg: f32, out: f32) -> Pos2 { + let r = ring.width() / 2.0 - 4.0 - 7.0 + out; + let a = deg.to_radians(); + ring.center() + vec2(a.cos(), -a.sin()) * r +} + +/// The point (x across, y up; 0..1 each) of the square. +fn in_square(sq: Rect, x: f32, y: f32) -> Pos2 { + Pos2::new(sq.left() + x * sq.width(), sq.bottom() - y * sq.height()) +} + +fn press(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2, modifiers: Modifiers) { + press(h, pos, true, modifiers); + press(h, pos, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +/// A click whose press and release come in one frame (`Harness::event` gives each event a frame of +/// its own). +fn quick_click(h: &mut Harness<'static, VectorcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.run_steps(1); + let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.input_mut().events.extend([button(true), button(false)]); + h.run_steps(2); +} + +/// A drag through `points`, pressed at the first and released at the last. +fn drag(h: &mut Harness<'static, VectorcraftApp>, points: &[Pos2], modifiers: Modifiers) { + let (Some(first), Some(last)) = (points.first(), points.last()) else { return }; + press(h, *first, true, modifiers); + for p in points { + for _ in 0..2 { + h.event(Event::PointerMoved(*p)); + h.run_steps(1); + } + } + press(h, *last, false, modifiers); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +fn key(h: &mut Harness<'static, VectorcraftApp>, key: egui::Key, modifiers: Modifiers) { + h.key_press_modifiers(modifiers, key); + h.run_steps(1); +} + +fn fill(app: &VectorcraftApp) -> Color { + crate::panels::current_paints(app).0.color().expect("a solid fill") +} + +fn hsb(c: &Color) -> [f32; 3] { + display_hsb(&cms::active(), c) +} + +fn near(a: [f32; 3], b: [f32; 3], tol: [f32; 3]) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol[i]) +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +/// Window › Color Wheel opens the panel, and a click on the left of the ring turns a red fill cyan +/// at the same saturation and brightness. +#[test] +fn window_color_wheel_opens_the_panel_and_the_ring_picks_the_hue() { + let mut app = app(json!("#ff0000")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Color Wheel")); + let item = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color Wheel"); + assert!(item.is_some(), "the Window menu lists it"); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + let undo = undo_len(h.state()); + click(&mut h, on_ring(ring, 180.0, 0.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + assert_eq!(undo_len(h.state()), undo + 1, "one undo step"); +} + +#[test] +fn the_square_picks_saturation_across_and_brightness_up() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, sq.right_top() + vec2(-0.5, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); + click(&mut h, in_square(sq, 0.25, 0.5), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 0.25, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// A press between the square and the ring's inner edge picks the closest point of the square. +#[test] +fn a_press_inside_the_ring_beside_the_square_picks_its_closest_point() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let sq = rect_of(&h, "Saturation and brightness"); + // 1.4 points beyond the corner along the diagonal: the band's inner edge is 2 points out. + click(&mut h, sq.right_top() + vec2(1.0, -1.0), Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [0.0, 1.0, 1.0], [0.5, 1e-4, 1e-4]), "{:?}", hsb(&fill(h.state()))); +} + +/// The part a press starts on keeps the drag: from the ring into the square only the hue changes. +#[test] +fn a_drag_from_the_ring_into_the_square_changes_only_the_hue() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + let before = hsb(&fill(h.state())); + drag(&mut h, &[on_ring(ring, 90.0, 0.0), sq.center() + vec2(-10.0, 0.0)], Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [180.0, before[1], before[2]], [0.5, 1e-3, 1e-3]), "{before:?} → {after:?}"); +} + +/// A press up to 4 points outside the band still picks a hue; one in the rect's corner does nothing. +#[test] +fn a_press_just_outside_the_ring_picks_a_hue_and_the_corners_do_nothing() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 180.0, 7.0 + 3.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + assert_eq!(fill(h.state()).to_hex(), "#00ffff"); +} + +/// Each click takes the part of the ring under it, also when its press and release come in one +/// frame, and a click on the band gives the ring the keyboard focus. +#[test] +fn a_click_in_one_frame_takes_the_part_under_it() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let (ring, sq) = (rect_of(&h, "Hue ring"), rect_of(&h, "Saturation and brightness")); + click(&mut h, ring.left_top() + vec2(2.0, 2.0), Modifiers::NONE); + quick_click(&mut h, on_ring(ring, 120.0, 0.0)); + assert!(near(hsb(&fill(h.state())), [120.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "after a corner: {:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [121.0, 0.5, 0.6], [0.5, 1e-3, 1e-3]), "focused: {:?}", hsb(&fill(h.state()))); + quick_click(&mut h, sq.right_top() + vec2(1.0, -1.0)); + assert!(near(hsb(&fill(h.state())), [121.0, 1.0, 1.0], [0.5, 1e-3, 1e-3]), "after the band: {:?}", hsb(&fill(h.state()))); +} + +#[test] +fn shift_drag_on_the_ring_steps_the_hue_by_45_degrees() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + drag(&mut h, &[on_ring(ring, 10.0, 0.0), on_ring(ring, 60.0, 0.0), on_ring(ring, 100.0, 0.0)], Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [90.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +#[test] +fn alt_click_paints_the_stroke_and_the_fill_stays_in_front() { + let mut app = app(json!("#ff0000")); + app.run("paint.setStroke", json!({"color": "#808080", "focus": false})).unwrap(); + assert!(app.session.fill_active); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::ALT); + let (f, s) = crate::panels::current_paints(h.state()); + assert_eq!((f.color().unwrap().to_hex(), s.color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into())); + assert!(h.state().session.fill_active, "the fill stays in front"); +} + +#[test] +fn a_pick_recolors_the_selected_gradient_stop() { + let mut app = app(json!("#ff0000")); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap(); + app.select_tool("gradient"); + app.run("gradient.selectStop", json!({"index": 1})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 120.0, 0.0), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hex: Vec = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(); + assert_eq!(hex, ["#ff0000", "#00ff00"]); +} + +/// With None there is no color to edit: no markers, and a click sets the fill. +#[test] +fn with_a_none_fill_the_wheel_has_no_marker_and_a_click_sets_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let c = fill(h.state()); + assert!(near(hsb(&c), [hsb(&h.state().session.last_solid)[0], 0.5, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&c)); +} + +/// The hue stays while the square passes through a gray: a drag to the left edge (white, whose own +/// hue is 0°) and back ends at the color a click at its end gives, for an RGB and for a CMYK fill +/// (whose stored color differs from the picked RGB), and an RGB green is green again. +#[test] +fn a_gray_keeps_the_hue_it_was_dragged_from() { + for fill_json in [json!("#00ff00"), json!({"c": 0.8, "m": 0.1, "y": 0.9, "k": 0.0})] { + let mut h = harness(app(fill_json.clone())); + let before = hsb(&fill(h.state())); + let sq = rect_of(&h, "Saturation and brightness"); + // Inside the square's edges; the drag past its left edge picks saturation 0. + let at = |x: f32| in_square(sq, x.clamp(0.005, 0.995), before[2].clamp(0.005, 0.995)); + let gray = Pos2::new(sq.left() - 5.0, at(0.0).y); + drag(&mut h, &[at(before[1]), gray, at(before[1])], Modifiers::NONE); + let dragged = fill(h.state()); + let mut direct = harness(app(fill_json.clone())); + click(&mut direct, at(before[1]), Modifiers::NONE); + assert_eq!(dragged.to_hex(), fill(direct.state()).to_hex(), "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + assert!((hsb(&dragged)[0] - before[0]).abs() < 5.0, "{fill_json}: {before:?} → {:?}", hsb(&dragged)); + } + let mut h = harness(app(json!("#00ff00"))); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() + 1.0), in_square(sq, 0.995, 0.995)], Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [120.0, 1.0, 1.0], [0.5, 0.01, 0.01]), "{:?}", hsb(&fill(h.state()))); +} + +/// The remembered hue belongs to the object it was picked on: another object of the same color +/// shows its own hue. +#[test] +fn the_remembered_hue_stays_with_its_object() { + let mut app = app(json!("#00ff00")); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone(); + app.run("paint.setFill", json!({"color": "#ffffff", "ids": [b]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + // Green to white (saturation 0 at the top edge): the wheel keeps hue 120°. + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + assert_eq!(fill(h.state()), Color::WHITE); + h.state_mut().run("select.set", json!({"ids": [b]})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&fill(h.state()))[0]; + assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}"); +} + +/// A CMYK fill stays CMYK and keeps its look (ΔE 2000 under 2) when its marker is clicked; the +/// working profile separates the picked color again, so its inks can move. +#[test] +fn a_cmyk_fill_stays_cmyk_and_keeps_its_look() { + let [c, m, y, k] = Color::rgb(0.2, 0.5, 0.7).to_cmyk_managed(cms::active().settings().intent); + for inks in [[c, m, y, k], [0.2, 0.2, 0.2, 0.0]] { + let mut h = harness(app(json!({"c": inks[0], "m": inks[1], "y": inks[2], "k": inks[3]}))); + let before = fill(h.state()); + let [_, s, b] = hsb(&before); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, s, b), Modifiers::NONE); + let after = fill(h.state()); + assert!(matches!(after, Color::Cmyk { .. }), "{after:?}"); + let cms = cms::active(); + assert!(delta_e2000(cms.lab(&before), cms.lab(&after)) < cms::GAMUT_THRESHOLD, "{before:?} → {after:?}"); + } +} + +/// A Gray fill becomes a process color in the document's color mode. +#[test] +fn a_gray_fill_becomes_rgb_or_cmyk_with_the_document() { + for (mode, rgb) in [("rgb", true), ("cmyk", false)] { + let mut app = app(json!("#ff0000")); + app.run("file.documentColorMode", json!({"mode": mode})).unwrap(); + app.run("paint.setFill", json!({"color": {"gray": 0.5}})).unwrap(); + let mut h = harness(app); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let c = fill(h.state()); + assert_eq!(matches!(c, Color::Rgb { .. }), rgb, "{mode}: {c:?}"); + assert_eq!(matches!(c, Color::Cmyk { .. }), !rgb, "{mode}: {c:?}"); + } +} + +/// In a working RGB space wider than sRGB a pick goes into it through the color settings, so a hue +/// change keeps the displayed saturation and brightness. +#[test] +fn a_pick_in_display_p3_keeps_the_components_it_does_not_change() { + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let was = Color::rgb(0.5, 0.3, 0.2); + let [h, s, b] = display_hsb(&p3, &was); + let new = stored(&p3, [h + 40.0, s, b], Some(&was), false); + let [h2, s2, b2] = display_hsb(&p3, &new); + assert!(matches!(new, Color::Rgb { .. })); + assert!((h2 - (h + 40.0)).abs() < 1.0 && (s2 - s).abs() < 0.01 && (b2 - b).abs() < 0.01, "{:?} → {:?}", [h, s, b], [h2, s2, b2]); +} + +/// A press gives the ring or the square the keyboard focus. The arrows then step the color, each +/// press one undo step, the app's tool keys wait, and after Escape they work again. +#[test] +fn the_arrow_keys_step_the_focused_ring_or_square() { + let mut h = harness(app(json!("#ff0000"))); + let ring = rect_of(&h, "Hue ring"); + click(&mut h, on_ring(ring, 0.0, 0.0), Modifiers::NONE); + let undo = undo_len(h.state()); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [1.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + key(&mut h, egui::Key::ArrowUp, Modifiers::SHIFT); + assert!(near(hsb(&fill(h.state())), [11.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + assert_eq!(undo_len(h.state()), undo + 2, "a step each"); + key(&mut h, egui::Key::ArrowLeft, Modifiers::SHIFT); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + assert!(near(hsb(&fill(h.state())), [359.0, 1.0, 1.0], [1e-3, 1e-3, 1e-3]), "wraps: {:?}", hsb(&fill(h.state()))); + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + let before = hsb(&fill(h.state())); + key(&mut h, egui::Key::ArrowUp, Modifiers::NONE); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [before[0], before[1] + 0.01, before[2] + 0.01], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + // The tool keys wait while a control has the focus. + let tool = h.state().session.tool_id().to_string(); + let p = |h: &mut Harness<'static, VectorcraftApp>| { + h.event(Event::Key { key: egui::Key::P, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("p".into())); + h.run_steps(1); + }; + p(&mut h); + assert_eq!(h.state().session.tool_id(), tool); + key(&mut h, egui::Key::Escape, Modifiers::NONE); + p(&mut h); + assert_eq!(h.state().session.tool_id(), "pen"); +} + +/// Screen readers find the ring as a slider from 0 to 360 that their actions move, and the square +/// with both of its components. +#[test] +fn screen_readers_read_and_move_the_ring_and_the_square() { + let mut h = harness(app(json!([0.6, 0.3, 0.3]))); + let ring = h.get_by_label("Hue ring"); + let node = ring.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(360.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert_eq!(node.numeric_value(), Some(0.0)); + let (target_node, target_tree) = node.locate(); + assert_eq!(h.get_by_label("Saturation and brightness").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [1.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); +} + +/// The Color Picker's color field is the same widget: a press focuses it, the arrows step the +/// components it shows, and screen readers read its name and both components. +#[test] +fn the_color_pickers_field_takes_the_arrow_keys() { + let mut app = app(json!([0.6, 0.3, 0.3])); + app.run("ui.colorPicker", json!({})).unwrap(); + let mut fonts = false; + let mut h = Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + // The fonts take effect from the next frame. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let field = rect_of(&h, "Color field"); + assert_eq!(h.get_by_label("Color field").value().as_deref(), Some("Saturation 50%, Brightness 60%")); + click(&mut h, field.center(), Modifiers::NONE); + let picked = |h: &Harness<'static, VectorcraftApp>| -> [f32; 3] { + serde_json::from_value(h.state().ui.dialog.as_ref().unwrap().fields["__hsb"].clone()).unwrap() + }; + let before = picked(&h); + key(&mut h, egui::Key::ArrowRight, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::SHIFT); + let after = picked(&h); + assert!(near(after, [before[0], before[1] + 0.01, before[2] - 0.1], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + assert!(h.state().ui.dialog.is_some(), "the dialog stays open"); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1b93832ae..d7f3cc579 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -201,6 +201,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("attributes", "Attributes", "settings"), (crate::panels::svg_interactivity::ID, "SVG Interactivity", "spline-pointer"), ("colorThemes", "Color Themes", "sun"), + (crate::panels::color_wheel::ID, "Color Wheel", "circle"), (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 20cfd68b3..4f3243c75 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -33,12 +33,12 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; -pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use color::{ - Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, - color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color, - segmented_wheel, stepped_area, + FieldAxis, Live, RingResponse, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, + color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, hsb32, hue_ring, + region_texture, ring_square, segment_color, segmented_wheel, stepped_area, }; +pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use drag::{ PanelDrag, RowClick, SwatchRows, apply_row_click, art_drop, doc_preview, drag_ghost, drag_source, drop_target_outline, ghost_row, row_click, }; diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 72f74f7b9..5fe8bcbbb 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,5 +1,5 @@ use super::labels::{label_field, name_field, take_previous_label}; -use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; +use super::numeric::{STEP_DECIMALS, STEPS, field_text, round_to, step_to_multiple}; use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus}; use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; @@ -320,30 +320,106 @@ fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn( } } -/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y -/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +/// One axis of a [`color_field`] as keys and screen readers see it: the component's name, how many +/// of its units the axis spans (an arrow press moves one unit) and the units' suffix. +#[derive(Clone, Copy, Debug)] +pub struct FieldAxis<'a> { + pub name: &'a str, + pub units: f32, + pub suffix: &'a str, +} + +/// A press on `resp`, or a click whose press and release came in one frame, gives it the keyboard +/// focus, so the arrow keys reach it. Escape or a click elsewhere takes the focus away again. +fn focus_on_press(resp: &Response) { + if resp.is_pointer_button_down_on() || resp.clicked() { + resp.request_focus(); + } +} + +/// The arrow keys pressed for control `id` this frame while it has the keyboard focus, in units +/// ([`STEPS`]: ten with Shift, a tenth with Ctrl/Cmd, else one): (→ minus ←, ↑ minus ↓). The control +/// takes the arrows of each axis it uses (`horizontal`, `vertical`), which then do not move the focus +/// to the next widget. +fn arrow_steps(ui: &Ui, id: egui::Id, horizontal: bool, vertical: bool) -> (f32, f32) { + if !ui.memory(|m| m.has_focus(id)) { + return (0.0, 0.0); + } + use egui::Key; + let filter = egui::EventFilter { horizontal_arrows: horizontal, vertical_arrows: vertical, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let (mut x, mut y) = (0.0, 0.0); + ui.input_mut(|i| { + for (mods, size) in STEPS { + let mut count = |key| i.count_and_consume_key(mods, key) as f64 * size; + if horizontal { + x += count(Key::ArrowRight) - count(Key::ArrowLeft); + } + if vertical { + y += count(Key::ArrowUp) - count(Key::ArrowDown); + } + } + }); + (x as f32, y as f32) +} + +/// The focus outline of a focused color control. +fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { + if ui.memory(|m| m.has_focus(resp.id)) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(rect.expand(2.0), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Outside); + } +} + +/// A 2D color field (the Color Picker's, the Color Wheel panel's square): `color_at(x, y)` gives +/// the color at a point (0..1 each, y down) and a ring marks `pos` (no ring for `None`). A press +/// gives it the keyboard focus. While it has the focus, → and ← move the point across by one unit +/// of `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`. Screen readers read +/// `name` and both components. Returns the clicked or dragged point, or the point the keys moved +/// to, and the phase. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, + (name, axes): (&str, [FieldAxis<'_>; 2]), size: Vec2, - pos: (f32, f32), + pos: Option<(f32, f32)>, color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + focus_on_press(&resp); let painter = ui.painter_at(rect.expand(6.0)); painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); - painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); - painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); - match pointer_phase(&resp) { - Some((p, phase)) => { - let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); - (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) + focus_outline(ui, &resp, rect); + if let Some((x, y)) = pos { + let c = pos2(rect.left() + x.clamp(0.0, 1.0) * rect.width(), rect.top() + y.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); + } + let value = |v: f32, a: &FieldAxis| format!("{}{}", (v.clamp(0.0, 1.0) * a.units).round(), a.suffix); + let text = pos.map(|(x, y)| { + crate::i18n::fmt( + tl!("{name1} {value1}, {name2} {value2}"), + &[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))], + ) + }); + resp.widget_info(|| egui::WidgetInfo { + current_text_value: text.clone(), + ..egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name) + }); + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + if let Some((p, phase)) = pointer_phase(&resp) { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); + } + match pos { + Some((x, y)) if dx != 0.0 || dy != 0.0 => { + let moved = ((x + dx / axes[0].units).clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + (Some(moved), Live::Released) } - None => (None, Live::Idle), + _ => (None, Live::Idle), } } @@ -628,6 +704,150 @@ fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { egui::Shape::mesh(mesh) } +/// The width of a [`hue_ring`]'s band. +const RING_WIDTH: f32 = 14.0; +/// How far inside its rect a [`hue_ring`]'s band is drawn: a press up to this far outside the band +/// still picks a hue. +const RING_MARGIN: f32 = 4.0; + +/// The display sRGB of HSB `[h°, s, v]` as an egui color, with no color conversion. +pub fn hsb32(hsb: [f32; 3]) -> Color32 { + let [r, g, b] = vectorcraft_color::hsb_to_rgb(hsb).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + Color32::from_rgb(r, g, b) +} + +/// The square a [`hue_ring`] drawn in `rect` holds: the largest one inside the band's inner edge, +/// 2 points clear of it. +pub fn ring_square(rect: Rect) -> Rect { + let inner = rect.width().min(rect.height()) / 2.0 - RING_MARGIN - RING_WIDTH - 2.0; + Rect::from_center_size(rect.center(), Vec2::splat(inner.max(0.0) * std::f32::consts::SQRT_2)) +} + +/// The part of a [`hue_ring`] a press started on. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RingPart { + Band, + Inner, + Corner, +} + +/// What a [`hue_ring`] did this frame. +pub struct RingResponse { + /// The rect the ring is drawn in. + pub rect: Rect, + /// A new hue (degrees, 0..360) from a press or drag on the band, an arrow key or a screen + /// reader, and its phase. + pub hue: Option<(f32, Live)>, + /// The pointer of a press or drag that started inside the band's inner edge, and its phase. + pub inner: Option<(Pos2, Live)>, +} + +/// A hue ring `size` across: a band of hues drawn [`RING_MARGIN`] inside its rect, the hue +/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` (none +/// for `None`). A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the +/// pointer (with Shift, in 45° steps) and gives the ring the keyboard focus. A press inside the +/// band's inner edge comes back as `inner`, and a press in the rect's corners does nothing. The part +/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise the hue by +/// 1° and ← and ↓ lower it ([`STEPS`]), and 360° wraps to 0°. Screen readers read it as a slider +/// named `name` from 0 to 360, which their increment, decrement and set-value actions move. +pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: Option) -> RingResponse { + use egui::accesskit::{Action, ActionData}; + const SEG: u32 = 72; + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let c = rect.center(); + let outer = size / 2.0 - RING_MARGIN; + let inner = outer - RING_WIDTH; + let mut mesh = egui::Mesh::default(); + for k in 0..=SEG { + let h = k as f32 * 360.0 / SEG as f32; + let col = hsb32([h, 1.0, 1.0]); + mesh.colored_vertex(wheel_pos(c, inner, h, 1.0), col); + mesh.colored_vertex(wheel_pos(c, outer, h, 1.0), col); + } + for k in 0..SEG { + let a = 2 * k; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a + 1, a + 3, a + 2); + } + let painter = ui.painter_at(rect); + painter.add(egui::Shape::mesh(mesh)); + painter.circle_stroke(c, outer, Stroke::new(1.0, t.border)); + painter.circle_stroke(c, inner, Stroke::new(1.0, t.border)); + if ui.memory(|m| m.has_focus(resp.id)) { + painter.circle_stroke(c, outer + 2.0, Stroke::new(1.0, t.accent)); + } + if let Some(h) = hue { + let m = wheel_pos(c, (inner + outer) / 2.0, h, 1.0); + painter.circle_stroke(m, RING_WIDTH / 2.0 - 1.0, Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(m, RING_WIDTH / 2.0, Stroke::new(1.0, Color32::BLACK)); + } + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(hue.unwrap_or(0.0)), name)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(360.0); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } + }); + let part_at = |p: Pos2| match p.distance(c) { + d if d < inner => RingPart::Inner, + d if d <= outer + RING_MARGIN => RingPart::Band, + _ => RingPart::Corner, + }; + let part_id = resp.id.with("part"); + if resp.is_pointer_button_down_on() + && ui.input(|i| i.pointer.primary_pressed()) + && let Some(p) = resp.interact_pointer_pos() + { + ui.data_mut(|d| d.insert_temp(part_id, part_at(p))); + } + let pointer = pointer_phase(&resp); + // A click takes the part under it; a drag keeps the part its press started on. + let part = match pointer { + Some((p, _)) if resp.clicked() => Some(part_at(p)), + Some((p, _)) => Some(ui.data(|d| d.get_temp::(part_id)).unwrap_or_else(|| part_at(p))), + None => ui.data(|d| d.get_temp::(part_id)), + }; + if resp.clicked() || resp.drag_stopped() { + ui.data_mut(|d| d.remove::(part_id)); + } + if part == Some(RingPart::Band) { + focus_on_press(&resp); + } + let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32), + _ => None, + }); + (steps + dx + dy, set) + }); + let mut out = RingResponse { rect, hue: None, inner: None }; + if let Some((p, phase)) = pointer { + match part.unwrap_or_else(|| part_at(p)) { + RingPart::Band => { + let v = p - c; + let h = (-v.y).atan2(v.x).to_degrees(); + let h = if ui.input(|i| i.modifiers.shift) { (h / 45.0).round() * 45.0 } else { h }; + out.hue = Some((h.rem_euclid(360.0), phase)); + } + RingPart::Inner => out.inner = Some((p, phase)), + RingPart::Corner => {} + } + } else if let Some(h) = hue { + let new = set.unwrap_or(h + steps); + if new != h { + out.hue = Some((new.rem_euclid(360.0), Live::Released)); + } + } + out +} + /// Hue sectors and saturation rings of the [`segmented_wheel`]: patches 15° of hue by 20% /// saturation wide, the sectors centered on red at 0°. const WHEEL_SECTORS: u32 = 24; diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index ac6b1ba8f..04ac7160f 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -192,7 +192,7 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { /// One step of a numeric field with each modifier, most specific first (a plain pattern would also /// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to /// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. -const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; +pub(super) const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; /// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next /// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 290619b2a..b7a3e1053 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -246,6 +246,11 @@ Color Themes panel (`window.panel {panel: "colorThemes"}`): its Create tab makes My Themes tab lists the saved themes; every action runs a command (`colorTheme.save`, `colorTheme.delete`, `colorTheme.addToSwatches`, `swatch.newGroup` for the theme being made), and `colorTheme.list` reads the library. +Color Wheel panel (`window.panel {panel: "colorWheel"}`): a hue ring around a saturation and brightness square. A +pick runs the commands the Color panel's sliders run: `paint.setFill` or `paint.setStroke` with `keepModel`, +`paint.editGradient` for a gradient's selected stop, and `paint.freeform.setPoint` for a freeform gradient's selected +point (the first stop or point when none is selected). + Edit Colors dialogs: `ui.colorBalanceDialog` opens Adjust Colors (`colorBalance`: fields `mode` `gray`/`rgb`/`cmyk`/ `global`, the channels `r` `g` `b` / `c` `m` `y` `k` / `gray` / `tint` (global mode) from −100 to 100, `convert`, `fill`, `stroke`, `preview`) and `ui.saturateDialog` opens Saturate (`saturate`: `intensity` −100..100, `preview`). Both preview on the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5a294fb68..77c557321 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1186,6 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. + - **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. From 9bd585ae471803d50c6102348bbcabc484158a8e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 23:23:36 -0700 Subject: [PATCH 24/33] i18n: Color Wheel panel rows for el, bg, sv, pt-br, nl, da, id and fi main now treats these catalogs as complete, so the panel's labels and screen-reader names need a row in each. --- crates/ui-egui/src/i18n/bg.tsv | 5 +++++ crates/ui-egui/src/i18n/da.tsv | 5 +++++ crates/ui-egui/src/i18n/el.tsv | 5 +++++ crates/ui-egui/src/i18n/fi.tsv | 5 +++++ crates/ui-egui/src/i18n/id.tsv | 5 +++++ crates/ui-egui/src/i18n/nl.tsv | 5 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 4 ++++ crates/ui-egui/src/i18n/sv.tsv | 5 +++++ 8 files changed, 39 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1a8c4c353..ae4345c6b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -551,6 +551,11 @@ font-technology Variable Променливи Color Table ({n} colors) Цветова таблица ({n} цвята) Color Theme Цветова тема Color Themes Цветови теми + Color Wheel Цветно колело + Hue ring Пръстен на оттенъка + Saturation and brightness Насищане и яркост + Color field Цветово поле + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Цветен тип: Color: Цвят: Colorize Оцветяване diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 429738169..95da56d2a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -654,6 +654,11 @@ Color Farve Color Guide Farveguide Color Themes Farvetemaer + Color Wheel Farvehjul + Hue ring Ring for farvetone + Saturation and brightness Mætning og lysstyrke + Color field Farvefelt + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-egenskaber Document Info Dokumentoplysninger Flattener Preview Udjævningsforhåndsvisning diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index cccf88b15..beac9fe15 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -526,6 +526,11 @@ font-technology Variable Μεταβλητές Color Table ({n} colors) Πίνακας χρωμάτων ({n} χρώματα) Color Theme Χρωματικό θέμα Color Themes Χρωματικά θέματα + Color Wheel Χρωματικός τροχός + Hue ring Δακτύλιος απόχρωσης + Saturation and brightness Κορεσμός και φωτεινότητα + Color field Πεδίο χρώματος + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Τύπος χρώματος: Color: Χρώμα: Colorize Χρωματισμός diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 08bc88ade..1f73b4a68 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -696,6 +696,11 @@ Color Väri Color Guide Väriopas Color Themes Väriteemat + Color Wheel Väriympyrä + Hue ring Sävyrengas + Saturation and brightness Kylläisyys ja kirkkaus + Color field Värikenttä + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-ominaisuudet Document Info Asiakirjan tiedot Flattener Preview Litistyksen esikatselu diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 054938af1..b7245f13e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -654,6 +654,11 @@ Color Warna Color Guide Panduan warna Color Themes Tema warna + Color Wheel Roda warna + Hue ring Cincin rona + Saturation and brightness Saturasi dan kecerahan + Color field Kolom warna + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties Properti CSS Document Info Info dokumen Flattener Preview Pratinjau perata diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f9c6e6377..436de567a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -668,6 +668,11 @@ Color Kleur Color Guide Kleurhulp Color Themes Kleurthema's + Color Wheel Kleurenwiel + Hue ring Tintring + Saturation and brightness Verzadiging en helderheid + Color field Kleurveld + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS-eigenschappen Document Info Documentinfo Flattener Preview Afvlakvoorbeeld diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b579ae093..a8080b6ef 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -512,6 +512,10 @@ antiAlias Sharp Nítido Color Table Tabela de Cores Color Themes Temas de Cores Color Wheel Roda de Cores + Hue ring Anel de Tom + Saturation and brightness Saturação e Brilho + Color field Campo de Cor + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipo de Cor: Color blindness – Deuteranopia-type Daltonismo – tipo deuteranopia Color blindness – Protanopia-type Daltonismo – tipo protanopia diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 13dce154f..08ff7d8a3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -553,6 +553,11 @@ font-technology Variable Variabla Color Table ({n} colors) Färgtabell ({n} färger) Color Theme Färgtema Color Themes Färgteman + Color Wheel Färghjul + Hue ring Nyansring + Saturation and brightness Mättnad och ljusstyrka + Color field Färgfält + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Färgtyp: Color: Färg: Colorize Färglägg From 0fb0243a4f036520c8a35728d6cb516ea75d4379 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 11:07:07 -0700 Subject: [PATCH 25/33] Docs: the Color Wheel's progress line says scores and estimates are unchanged --- 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 77c557321..1be5d9f9c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1186,7 +1186,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. - **Missing library files:** a persistent swatch or graphic style library panel whose file is gone at startup reopens and stays in the preferences, showing the file's path with Locate Library… (`ui.libraryPanel.locate`), which reads the file from where it is now into the same panel. - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. - - **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. + - **Color Wheel panel (part of #978):** Window › Color Wheel shows the Fill and Stroke proxy beside a hue ring around a square with saturation across and brightness up. It edits what the Color panel's sliders edit (the active proxy's color, the selected gradient stop or freeform point) through the same paint commands, and a drag is one undo step. Alt paints the other proxy, and Shift steps the ring by 45°. A pick goes from display sRGB into the working RGB space; an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch becomes a process color in the document's color mode. A click gives the ring or the square the keyboard focus, and the arrow keys then step the color; both report their names and values to screen readers, and the Color Picker's color field takes the same keys. Scores and estimates are unchanged. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB grays separate into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. From ab7d97ad289c2590e9b2f6605bcb59c9a3725548 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 11:42:46 -0700 Subject: [PATCH 26/33] Color Wheel: with no solid color the arrow keys and screen readers set one With None, a pattern or a mixed paint the hue ring and the square drew no marker and also ignored the arrow keys and screen readers' increment, decrement and set-value actions, so only a pointer could set a color. They now step from the last solid color the panel shows; the marker and the values screen readers read still need a solid color. --- crates/ui-egui/src/dialogs/color_picker.rs | 2 +- crates/ui-egui/src/panels/color_wheel.rs | 7 ++- .../ui-egui/src/panels/tests_color_wheel.rs | 26 +++++++++ crates/ui-egui/src/widgets/color.rs | 54 ++++++++++--------- 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 73d9a532d..c4ef1a984 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -385,7 +385,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { let (z, x, y) = field_pos(channel, &p); let at = |x: f32, y: f32| c32(&field_pick(channel, z, x, y).snapped(web).color); if let (Some((x, y)), _) = - widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), Some((x, y)), &at) + widgets::color_field(ui, "cp-field", (tl!("Color field"), channel.field_axes()), vec2(side, side), (x, y), true, &at) { pick = Some(field_pick(channel, z, x, y)); } diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs index 6df65411d..f466584c0 100644 --- a/crates/ui-egui/src/panels/color_wheel.rs +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -103,7 +103,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let color = tgt.color(); let cms = cms::active(); // With None, a pattern or a "?" proxy the wheel shows the last solid color without markers, - // and a click applies the picked color. + // and a click, an arrow key or a screen reader's action applies the picked color. let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { (Some(c), Some((owner, hsb))) if owner == Owner::of(app, &tgt, c) => hsb, (c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)), @@ -116,15 +116,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::proxy(app, ui, 40.0); }); let size = ui.available_width().min(MAX_WHEEL); - let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, marked.then_some(hsb[0])); + let ring = widgets::hue_ring(ui, "color-wheel-ring", tl!("Hue ring"), size, hsb[0], marked); let square = widgets::ring_square(ring.rect); let mut field_ui = ui.new_child(UiBuilder::new().id_salt("color-wheel-square").max_rect(square)); let axes = [FieldAxis { name: tl!("Saturation"), units: 100.0, suffix: "%" }, FieldAxis { name: tl!("Brightness"), units: 100.0, suffix: "%" }]; let at = |x: f32, y: f32| widgets::hsb32([hsb[0], x, 1.0 - y]); - let pos = marked.then_some((hsb[1], 1.0 - hsb[2])); let name = tl!("Saturation and brightness"); - let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), pos, &at); + let (field, field_phase) = widgets::color_field(&mut field_ui, "field", (name, axes), square.size(), (hsb[1], 1.0 - hsb[2]), marked, &at); if let Some((h, phase)) = ring.hue { pick = Some(([h, hsb[1], hsb[2]], phase)); } diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index b5498e93d..61a9530a3 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -236,6 +236,32 @@ fn with_a_none_fill_the_wheel_has_no_marker_and_a_click_sets_the_fill() { assert!(near(hsb(&c), [hsb(&h.state().session.last_solid)[0], 0.5, 0.5], [0.5, 0.01, 0.01]), "{:?}", hsb(&c)); } +/// With None the ring and the square still take a screen reader's actions and the arrow keys. They +/// start from the last solid color the wheel shows, and the result sets the fill. +#[test] +fn with_a_none_fill_screen_readers_and_the_arrow_keys_set_the_fill() { + let mut app = app(json!("#ff0000")); + app.run("paint.setFill", json!({"none": true})).unwrap(); + let mut h = harness(app); + let last = hsb(&h.state().session.last_solid); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(200.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, last[1], last[2]], [0.5, 2e-3, 2e-3]), "{:?}", hsb(&fill(h.state()))); + // A click focuses the square; with the fill set to None again the arrows still step it. + let sq = rect_of(&h, "Saturation and brightness"); + click(&mut h, in_square(sq, 0.5, 0.5), Modifiers::NONE); + h.state_mut().run("paint.setFill", json!({"none": true})).unwrap(); + h.run_steps(1); + assert_eq!(h.get_by_label("Saturation and brightness").value(), None, "no marker"); + let last = hsb(&h.state().session.last_solid); + key(&mut h, egui::Key::ArrowLeft, Modifiers::NONE); + key(&mut h, egui::Key::ArrowDown, Modifiers::NONE); + let after = hsb(&fill(h.state())); + assert!(near(after, [last[0], last[1] - 0.01, last[2] - 0.01], [0.5, 2e-3, 2e-3]), "{last:?} → {after:?}"); +} + /// The hue stays while the square passes through a gray: a drag to the left edge (white, whose own /// hue is 0°) and back ends at the color a click at its end gives, for an RGB and for a CMYK fill /// (whose stored color differs from the picked RGB), and an RGB green is green again. diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 5fe8bcbbb..6d1ac43ab 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -372,17 +372,18 @@ fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { } /// A 2D color field (the Color Picker's, the Color Wheel panel's square): `color_at(x, y)` gives -/// the color at a point (0..1 each, y down) and a ring marks `pos` (no ring for `None`). A press -/// gives it the keyboard focus. While it has the focus, → and ← move the point across by one unit -/// of `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`. Screen readers read -/// `name` and both components. Returns the clicked or dragged point, or the point the keys moved -/// to, and the phase. +/// the color at a point (0..1 each, y down) and, when `marked`, a ring marks `pos`. A press gives +/// it the keyboard focus. While it has the focus, → and ← move `pos` across by one unit of +/// `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`, marked or not. Screen +/// readers read `name` and, when `marked`, both components. Returns the clicked or dragged point, +/// or the point the keys moved to, and the phase. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, (name, axes): (&str, [FieldAxis<'_>; 2]), size: Vec2, - pos: Option<(f32, f32)>, + pos: (f32, f32), + marked: bool, color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { let t = Tokens::get(ui.ctx()); @@ -393,13 +394,14 @@ pub fn color_field( painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); focus_outline(ui, &resp, rect); - if let Some((x, y)) = pos { + let (x, y) = pos; + if marked { let c = pos2(rect.left() + x.clamp(0.0, 1.0) * rect.width(), rect.top() + y.clamp(0.0, 1.0) * rect.height()); painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); } let value = |v: f32, a: &FieldAxis| format!("{}{}", (v.clamp(0.0, 1.0) * a.units).round(), a.suffix); - let text = pos.map(|(x, y)| { + let text = marked.then(|| { crate::i18n::fmt( tl!("{name1} {value1}, {name2} {value2}"), &[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))], @@ -414,13 +416,11 @@ pub fn color_field( let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); } - match pos { - Some((x, y)) if dx != 0.0 || dy != 0.0 => { - let moved = ((x + dx / axes[0].units).clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); - (Some(moved), Live::Released) - } - _ => (None, Live::Idle), + if dx != 0.0 || dy != 0.0 { + let moved = ((x + dx / axes[0].units).clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + return (Some(moved), Live::Released); } + (None, Live::Idle) } /// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 @@ -743,14 +743,15 @@ pub struct RingResponse { } /// A hue ring `size` across: a band of hues drawn [`RING_MARGIN`] inside its rect, the hue -/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` (none -/// for `None`). A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the +/// increasing counterclockwise from 3 o'clock as on [`harmony_wheel`], with a marker at `hue` when +/// `marked`. A press on the band, or up to [`RING_MARGIN`] outside it, picks the hue under the /// pointer (with Shift, in 45° steps) and gives the ring the keyboard focus. A press inside the /// band's inner edge comes back as `inner`, and a press in the rect's corners does nothing. The part -/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise the hue by -/// 1° and ← and ↓ lower it ([`STEPS`]), and 360° wraps to 0°. Screen readers read it as a slider -/// named `name` from 0 to 360, which their increment, decrement and set-value actions move. -pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: Option) -> RingResponse { +/// a press starts on keeps the whole drag. While the ring has the focus, → and ↑ raise `hue` by +/// 1° and ← and ↓ lower it ([`STEPS`]), marked or not, and 360° wraps to 0°. Screen readers read it +/// as a slider named `name` from 0 to 360 (0 when not `marked`), which their increment, decrement +/// and set-value actions move. +pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hue: f32, marked: bool) -> RingResponse { use egui::accesskit::{Action, ActionData}; const SEG: u32 = 72; let t = Tokens::get(ui.ctx()); @@ -778,12 +779,12 @@ pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hu if ui.memory(|m| m.has_focus(resp.id)) { painter.circle_stroke(c, outer + 2.0, Stroke::new(1.0, t.accent)); } - if let Some(h) = hue { - let m = wheel_pos(c, (inner + outer) / 2.0, h, 1.0); + if marked { + let m = wheel_pos(c, (inner + outer) / 2.0, hue, 1.0); painter.circle_stroke(m, RING_WIDTH / 2.0 - 1.0, Stroke::new(1.5, Color32::WHITE)); painter.circle_stroke(m, RING_WIDTH / 2.0, Stroke::new(1.0, Color32::BLACK)); } - resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(hue.unwrap_or(0.0)), name)); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), if marked { f64::from(hue) } else { 0.0 }, name)); ui.ctx().accesskit_node_builder(resp.id, |node| { node.set_min_numeric_value(0.0); node.set_max_numeric_value(360.0); @@ -839,9 +840,10 @@ pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hu RingPart::Inner => out.inner = Some((p, phase)), RingPart::Corner => {} } - } else if let Some(h) = hue { - let new = set.unwrap_or(h + steps); - if new != h { + } else { + let new = set.unwrap_or(hue + steps); + // Without a marker there is no color yet, so a set value applies even when it equals `hue`. + if new != hue || (set.is_some() && !marked) { out.hue = Some((new.rem_euclid(360.0), Live::Released)); } } From 22a439edb7a8c33a2b0392e3365deaca2ccdd36e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 14:31:18 -0700 Subject: [PATCH 27/33] Color Wheel: screen readers' actions move the square's saturation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The saturation and brightness square, and the Color Picker's color field it shares `widgets::color_field` with, gave screen readers its name and both values but took none of their actions, so an increment, decrement or set-value on it left the color as it was. The field is now a slider over its first component (saturation on the Color Wheel), from 0 to that component's units with a step of 1, as the hue ring is from 0 to 360. Increment and decrement move it one unit as → and ← do, and set-value sets it; the second component stays where it is. Without a marker the value reads 0 and a set value still applies, as on the ring. `screen_readers_read_and_move_the_ring_and_the_square` checks the square's range, step and actions, an increment, a set value and a decrement, and `the_color_pickers_field_takes_the_arrow_keys` checks an increment on the Color Picker's field. --- .../ui-egui/src/panels/tests_color_wheel.rs | 25 ++++++++++++-- crates/ui-egui/src/widgets/color.rs | 34 +++++++++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index 61a9530a3..8574a2900 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -394,7 +394,7 @@ fn the_arrow_keys_step_the_focused_ring_or_square() { } /// Screen readers find the ring as a slider from 0 to 360 that their actions move, and the square -/// with both of its components. +/// with both of its components, whose actions move its saturation as → and ← do. #[test] fn screen_readers_read_and_move_the_ring_and_the_square() { let mut h = harness(app(json!([0.6, 0.3, 0.3]))); @@ -412,10 +412,26 @@ fn screen_readers_read_and_move_the_ring_and_the_square() { h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); h.run_steps(1); assert!(near(hsb(&fill(h.state())), [200.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let square = h.get_by_label("Saturation and brightness"); + let node = square.accesskit_node(); + assert_eq!((node.min_numeric_value(), node.max_numeric_value(), node.numeric_value_step()), (Some(0.0), Some(100.0), Some(1.0))); + assert!([Action::Increment, Action::Decrement, Action::SetValue].iter().all(|a| node.data().supports_action(*a))); + assert!(node.numeric_value().is_some_and(|v| (v - 50.0).abs() < 1e-3), "{:?}", node.numeric_value()); + let (target_node, target_tree) = node.locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.51, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + let data = Some(ActionData::NumericValue(20.0)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.2, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Decrement, data: None })); + h.run_steps(1); + assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); } /// The Color Picker's color field is the same widget: a press focuses it, the arrows step the -/// components it shows, and screen readers read its name and both components. +/// components it shows, and screen readers read its name and both components and step the first. #[test] fn the_color_pickers_field_takes_the_arrow_keys() { let mut app = app(json!([0.6, 0.3, 0.3])); @@ -445,5 +461,10 @@ fn the_color_pickers_field_takes_the_arrow_keys() { key(&mut h, egui::Key::ArrowDown, Modifiers::SHIFT); let after = picked(&h); assert!(near(after, [before[0], before[1] + 0.01, before[2] - 0.1], [1e-3, 1e-4, 1e-4]), "{before:?} → {after:?}"); + let (target_node, target_tree) = h.get_by_label("Color field").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + let stepped = picked(&h); + assert!(near(stepped, [after[0], after[1] + 0.01, after[2]], [1e-3, 1e-4, 1e-4]), "{after:?} → {stepped:?}"); assert!(h.state().ui.dialog.is_some(), "the dialog stays open"); } diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 6d1ac43ab..159f33a49 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -375,8 +375,10 @@ fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { /// the color at a point (0..1 each, y down) and, when `marked`, a ring marks `pos`. A press gives /// it the keyboard focus. While it has the focus, → and ← move `pos` across by one unit of /// `axes[0]` and ↑ and ↓ move it up and down by one unit of `axes[1]`, marked or not. Screen -/// readers read `name` and, when `marked`, both components. Returns the clicked or dragged point, -/// or the point the keys moved to, and the phase. +/// readers read `name` and, when `marked`, both components. They read it as a slider over +/// `axes[0]`'s units (0 when not `marked`), which their increment and decrement actions move as → +/// and ← do and their set-value action sets, as for a [`hue_ring`]. Returns the clicked or dragged +/// point, or the point the keys or actions moved to, and the phase. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, @@ -386,6 +388,7 @@ pub fn color_field( marked: bool, color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { + use egui::accesskit::{Action, ActionData}; let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); @@ -407,17 +410,34 @@ pub fn color_field( &[("name1", axes[0].name), ("value1", &value(x, &axes[0])), ("name2", axes[1].name), ("value2", &value(1.0 - y, &axes[1]))], ) }); - resp.widget_info(|| egui::WidgetInfo { - current_text_value: text.clone(), - ..egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name) + let shown = if marked { f64::from(x.clamp(0.0, 1.0) * axes[0].units) } else { 0.0 }; + resp.widget_info(|| egui::WidgetInfo { current_text_value: text.clone(), ..egui::WidgetInfo::slider(ui.is_enabled(), shown, name) }); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(0.0); + node.set_max_numeric_value(f64::from(axes[0].units)); + node.set_numeric_value_step(1.0); + for action in [Action::SetValue, Action::Increment, Action::Decrement] { + node.add_action(action); + } }); let (dx, dy) = arrow_steps(ui, resp.id, true, true); + let (steps, set) = ui.input(|i| { + let steps = + i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; + let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32), + _ => None, + }); + (steps, set) + }); if let Some((p, phase)) = pointer_phase(&resp) { let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); } - if dx != 0.0 || dy != 0.0 { - let moved = ((x + dx / axes[0].units).clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + let across = set.map_or(x + (dx + steps) / axes[0].units, |v| v / axes[0].units); + // Without a marker there is no color yet, so a set value applies even when it equals `x`. + if across != x || dy != 0.0 || (set.is_some() && !marked) { + let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); return (Some(moved), Live::Released); } (None, Live::Idle) From 0a8446514bdc516fa204476cb67bce208f89d529 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 21:43:21 -0700 Subject: [PATCH 28/33] Color Wheel: a step past the edge of the square leaves the color as it is An arrow key or a screen reader's step outward at the edge of the square returned the clamped point, which equals the current one, and the wheel picked it again. For a CMYK fill the pick separated the color again, so one step at 100% saturation turned C100 into C71 M16 and added an undo step. color_field now returns no point when the clamped point is unchanged; with no marker a key or an action still sets a color. --- crates/ui-egui/src/panels/tests_color_wheel.rs | 15 +++++++++++++++ crates/ui-egui/src/widgets/color.rs | 11 +++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index 8574a2900..a3dfa3fba 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -430,6 +430,21 @@ fn screen_readers_read_and_move_the_ring_and_the_square() { assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); } +/// A step past the edge of the square leaves the fill alone: a CMYK cyan shows at 100% saturation, +/// and a step up from there neither separates it again nor adds an undo step. +#[test] +fn a_step_past_the_edge_of_the_square_changes_nothing() { + let mut h = harness(app(json!({"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}))); + let before = fill(h.state()); + assert_eq!(hsb(&before)[1], 1.0, "{:?}", hsb(&before)); + let undo = undo_len(h.state()); + let (target_node, target_tree) = h.get_by_label("Saturation and brightness").accesskit_node().locate(); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::Increment, data: None })); + h.run_steps(1); + assert_eq!(fill(h.state()), before); + assert_eq!(undo_len(h.state()), undo, "no undo step"); +} + /// The Color Picker's color field is the same widget: a press focuses it, the arrows step the /// components it shows, and screen readers read its name and both components and step the first. #[test] diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 159f33a49..744855cec 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -378,7 +378,8 @@ fn focus_outline(ui: &Ui, resp: &Response, rect: Rect) { /// readers read `name` and, when `marked`, both components. They read it as a slider over /// `axes[0]`'s units (0 when not `marked`), which their increment and decrement actions move as → /// and ← do and their set-value action sets, as for a [`hue_ring`]. Returns the clicked or dragged -/// point, or the point the keys or actions moved to, and the phase. +/// point, or the point the keys or actions moved to, and the phase. When `marked`, a step past an +/// edge returns no point. pub fn color_field( ui: &mut Ui, id: impl std::hash::Hash, @@ -435,9 +436,11 @@ pub fn color_field( return (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase); } let across = set.map_or(x + (dx + steps) / axes[0].units, |v| v / axes[0].units); - // Without a marker there is no color yet, so a set value applies even when it equals `x`. - if across != x || dy != 0.0 || (set.is_some() && !marked) { - let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + let moved = (across.clamp(0.0, 1.0), (y - dy / axes[1].units).clamp(0.0, 1.0)); + // A step past an edge leaves the point where it is. Without a marker there is no color yet, so + // a key or an action applies even when the point stays. + let asked = set.is_some() || dx + steps != 0.0 || dy != 0.0; + if moved != (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)) || (asked && !marked) { return (Some(moved), Live::Released); } (None, Live::Idle) From acde2ab30e473b03b09e6ab8a8811b1739ba7b1e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 06:52:38 -0700 Subject: [PATCH 29/33] Color Wheel: the remembered hue resets when the color settings change the color's display The wheel kept the HSB picked last while the objects and the color were unchanged, even after a change of the working RGB space or the document's profile made the same color display differently. It now also stores the display sRGB of the color it picked, and shows the color's own HSB when that no longer matches. --- crates/ui-egui/src/panels/color_wheel.rs | 17 ++++++++++++----- crates/ui-egui/src/panels/tests_color_wheel.rs | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs index f466584c0..7011c6c70 100644 --- a/crates/ui-egui/src/panels/color_wheel.rs +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -5,7 +5,7 @@ //! proxy and Shift steps the ring by 45°. A pick is display sRGB, converted into the working RGB //! space: an RGB, CMYK or Lab color keeps its model, and a Gray color or a tint of a global swatch //! becomes a process color in the document's color mode. The HSB picked last stays while the -//! objects, the proxy and the color are unchanged, so a gray keeps its hue. +//! objects, the proxy, the color and the color settings are unchanged, so a gray keeps its hue. use egui::{Ui, UiBuilder}; use vectorcraft_color::cms::{self, Cms}; @@ -20,7 +20,7 @@ use crate::widgets::{self, FieldAxis, Live}; pub const ID: &str = "colorWheel"; /// The widest the wheel is drawn. const MAX_WHEEL: f32 = 200.0; -/// Panel state: the HSB picked last and its [`Owner`]. +/// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored. const MEMORY: &str = "color-wheel-hsb"; /// What the HSB picked last belongs to: the document, the selected objects, the proxy, the @@ -59,6 +59,13 @@ pub(crate) fn display_hsb(cms: &Cms, c: &Color) -> [f32; 3] { rgb_to_hsb(cms.display_rgb(c)) } +/// The HSB the wheel shows for `c` through `cms` when `hsb` was picked last and `c` then displayed +/// as the sRGB `shown`: `hsb` while `c` still displays as `shown`, else `c`'s own display HSB (after +/// a change of the color settings or the document's profile). +pub(crate) fn remembered_hsb(cms: &Cms, c: &Color, shown: [f32; 3], hsb: [f32; 3]) -> [f32; 3] { + if cms.display_rgb(c) == shown { hsb } else { display_hsb(cms, c) } +} + /// The color a pick of display HSB `hsb` stores in place of `was`, through `cms`. The picked sRGB /// goes into the working RGB space. An RGB color stays RGB, and a CMYK or Lab color converts to its /// model with the color settings. Anything else (no color, a Gray color, a tint of a global swatch) @@ -104,8 +111,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let cms = cms::active(); // With None, a pattern or a "?" proxy the wheel shows the last solid color without markers, // and a click, an arrow key or a screen reader's action applies the picked color. - let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { - (Some(c), Some((owner, hsb))) if owner == Owner::of(app, &tgt, c) => hsb, + let hsb = match (color, pstate::>(ui.ctx(), MEMORY)) { + (Some(c), Some((owner, shown, hsb))) if owner == Owner::of(app, &tgt, c) => remembered_hsb(&cms, &c, shown, hsb), (c, _) => display_hsb(&cms, &c.unwrap_or(app.session.last_solid)), }; let marked = color.is_some(); @@ -144,7 +151,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk); let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc); if !behind { - set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), hsb))); + set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb))); } apply(app, &tgt, c, phase, behind); } diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index a3dfa3fba..83d78a5e3 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -11,7 +11,7 @@ use vectorcraft_color::cms::{self, Cms, ColorSettings, delta_e2000}; use vectorcraft_color::{Color, Paint}; use vectorcraft_engine::Session; -use super::color_wheel::{ID, display_hsb, show, stored}; +use super::color_wheel::{ID, display_hsb, remembered_hsb, show, stored}; use crate::VectorcraftApp; /// An app with a selected rectangle filled with `fill`. @@ -308,6 +308,21 @@ fn the_remembered_hue_stays_with_its_object() { assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}"); } +/// The remembered HSB holds only while the color displays as it did when picked: in another +/// working RGB space the wheel shows the color's own HSB there. +#[test] +fn the_remembered_hsb_follows_a_change_of_working_space() { + let srgb = Cms::default(); + let p3 = Cms::new(&ColorSettings { rgb: cms::DISPLAY_P3.into(), ..ColorSettings::default() }).unwrap(); + let c = Color::rgb(0.5, 0.3, 0.2); + let picked = [25.0, 0.6, 0.5]; + let shown = srgb.display_rgb(&c); + assert_eq!(remembered_hsb(&srgb, &c, shown, picked), picked); + let own = display_hsb(&p3, &c); + assert_ne!(own, picked); + assert_eq!(remembered_hsb(&p3, &c, shown, picked), own); +} + /// A CMYK fill stays CMYK and keeps its look (ΔE 2000 under 2) when its marker is clicked; the /// working profile separates the picked color again, so its inks can move. #[test] From ae8f0c3a14c5408275061306baa5466ee023065f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 09:07:45 -0700 Subject: [PATCH 30/33] Color Wheel: a screen reader's huge hue value wraps like any other hue A set value on the hue ring above the f32 range became infinite before it was wrapped into 0 to 360 degrees, so the ring dropped it. The value is now wrapped first. Catalan, now a complete catalog, gets the panel's rows. --- crates/ui-egui/src/i18n/ca.tsv | 5 +++++ crates/ui-egui/src/panels/tests_color_wheel.rs | 14 ++++++++++++++ crates/ui-egui/src/widgets/color.rs | 2 +- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b0bf6998d..49886d3ca 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -766,6 +766,11 @@ antiAlias Sharp Nítid Color Table ({n} colors) Taula de colors ({n} colors) Color Theme Tema de colors Color Themes Temes de colors + Color Wheel Roda de colors + Hue ring Anell de to + Saturation and brightness Saturació i lluentor + Color field Camp de color + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Tipus de color: Color wheel Roda de color Color: Color: diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index 83d78a5e3..be9856ed4 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -445,6 +445,20 @@ fn screen_readers_read_and_move_the_ring_and_the_square() { assert!(near(hsb(&fill(h.state())), [200.0, 0.19, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&fill(h.state()))); } +#[test] +fn a_huge_set_value_on_the_ring_wraps_like_any_other_hue() { + let mut h = harness(app(json!([0.3, 0.6, 0.3]))); + let (target_node, target_tree) = h.get_by_label("Hue ring").accesskit_node().locate(); + let data = Some(ActionData::NumericValue(1e300)); + h.event(Event::AccessKitActionRequest(ActionRequest { target_node, target_tree, action: Action::SetValue, data })); + h.run_steps(1); + let color = fill(h.state()); + let Color::Rgb { r, g, b } = color else { panic!("expected RGB fill: {color:?}") }; + assert!([r, g, b].iter().all(|v| v.is_finite()), "{color:?}"); + assert!(hsb(&color).iter().all(|v| v.is_finite()), "{:?}", hsb(&color)); + assert!(near(hsb(&color), [0.0, 0.5, 0.6], [1e-3, 1e-3, 1e-3]), "{:?}", hsb(&color)); +} + /// A step past the edge of the square leaves the fill alone: a CMYK cyan shows at 100% saturation, /// and a step up from there neither separates it again nor adds an undo step. #[test] diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 744855cec..f3d34d23e 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -846,7 +846,7 @@ pub fn hue_ring(ui: &mut Ui, id: impl std::hash::Hash, name: &str, size: f32, hu let steps = i.num_accesskit_action_requests(resp.id, Action::Increment) as f32 - i.num_accesskit_action_requests(resp.id, Action::Decrement) as f32; let set = i.accesskit_action_requests(resp.id, Action::SetValue).find_map(|r| match r.data { - Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v as f32), + Some(ActionData::NumericValue(v)) if v.is_finite() => Some(v.rem_euclid(360.0) as f32), _ => None, }); (steps + dx + dy, set) From 66d7d9315a1920d8e311f4fbc60f50fc4eac07ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 09:34:15 -0700 Subject: [PATCH 31/33] Color Wheel: the remembered hue stays with its appearance item, characters and gradient The wheel kept the hue of its last pick while the selected objects, the proxy, the slot and the color were unchanged. Switching to another fill row in the Appearance panel, another range of characters in the Type tool, or a new gradient with the same stop color kept that hue too, so a pick there could paint the earlier hue. The wheel now also checks the active appearance item, the selected characters and the gradient. --- crates/ui-egui/src/panels/color_wheel.rs | 17 +++-- .../ui-egui/src/panels/tests_color_wheel.rs | 69 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs index 7011c6c70..a2108d8cf 100644 --- a/crates/ui-egui/src/panels/color_wheel.rs +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -23,16 +23,22 @@ const MAX_WHEEL: f32 = 200.0; /// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored. const MEMORY: &str = "color-wheel-hsb"; -/// What the HSB picked last belongs to: the document, the selected objects, the proxy, the -/// gradient stop or freeform point, and the color the pick stored. The wheel shows that HSB only -/// while all of them are unchanged. +/// What the HSB picked last belongs to: the document, the selected objects or text characters, +/// the appearance item, the proxy, the gradient stop or freeform point, its paint, and the color +/// the pick stored. The wheel shows that HSB only while all of them are unchanged. #[derive(Clone, Debug, PartialEq)] struct Owner { doc: Option, selection: Vec, + /// The Type tool's selected characters, when it edits the paint. + text_selection: Option<(NodeId, usize, usize)>, + /// The Appearance panel's active fill or stroke row. + appearance_item: Option, fill: bool, /// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`). slot: Option<(bool, usize)>, + /// The gradient behind a selected stop or point, including its other colors. + gradient: Option, color: Color, } @@ -47,8 +53,11 @@ impl Owner { Self { doc: st.map(|d| d.uid), selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(), + text_selection: super::character::text_editing(app), + appearance_item: app.session.appearance_item(), fill: app.session.fill_active, slot, + gradient: slot.map(|_| super::active_paint(app)), color, } } @@ -150,8 +159,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let behind = super::alt_held(ui); let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk); let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc); + apply(app, &tgt, c, phase, behind); if !behind { set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb))); } - apply(app, &tgt, c, phase, behind); } diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index be9856ed4..798a842e4 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -308,6 +308,75 @@ fn the_remembered_hue_stays_with_its_object() { assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}"); } +/// Equal colors on two appearance fills have separate remembered hues. A square pick on the +/// second white fill uses its own 0° hue after the first fill was changed from green to white. +#[test] +fn the_remembered_hue_stays_with_its_appearance_item() { + let mut app = app(json!("#00ff00")); + let id = app.session.active().unwrap().selection.objects[0]; + app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill] + app.run("paint.setFill", json!({"item": 2, "color": "#ffffff"})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let item_fill = |app: &VectorcraftApp, index| { + app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_at(Some(index)).unwrap().paint.color().unwrap() + }; + assert_eq!(item_fill(h.state(), 0), Color::WHITE); + assert_eq!(item_fill(h.state(), 2), Color::WHITE); + h.state_mut().run("appearance.setActiveItem", json!({"index": 2})).unwrap(); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let hue = hsb(&item_fill(h.state(), 2))[0]; + assert!(!(1.0..359.0).contains(&hue), "the second fill's own hue is 0°, not 120°: {hue}"); + assert_eq!(item_fill(h.state(), 0), Color::WHITE); +} + +/// The Type tool can recolor a selected character range without changing the selected object. +/// The next range of the same color starts with its own hue. +#[test] +fn the_remembered_hue_stays_with_its_text_selection() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 10, "y": 100, "text": "AB", "color": "#00ff00"})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "fill": "#ffffff"})).unwrap(); + app.select_tool("type"); + app.session.set_tool_option("textSelection", &json!({"id": id, "start": 0, "end": 1})); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + h.state_mut().session.set_tool_option("textSelection", &json!({"id": id, "start": 1, "end": 2})); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let node = h.state().session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("still text") }; + assert_eq!(t.runs.first().unwrap().style.fill.color(), Some(Color::WHITE)); + let hue = hsb(&t.runs.last().unwrap().style.fill.color().unwrap())[0]; + assert!(!(1.0..359.0).contains(&hue), "the second character's own hue is 0°, not 120°: {hue}"); +} + +/// Replacing a gradient can keep the same selected stop and color while changing the paint it +/// belongs to. The new gradient starts with that stop's own hue. +#[test] +fn the_remembered_hue_stays_with_its_gradient() { + let mut app = app(json!("#00ff00")); + let first = json!([{"offset": 0, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]); + app.run("paint.setFill", json!({"gradient": {"stops": first}})).unwrap(); + app.run("gradient.selectStop", json!({"index": 0})).unwrap(); + let mut h = harness(app); + let sq = rect_of(&h, "Saturation and brightness"); + drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE); + let second = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]); + h.state_mut().run("paint.setFill", json!({"gradient": {"stops": second}})).unwrap(); + assert_eq!(h.state().session.selected_stop(), Some(0)); + h.run_steps(1); + click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE); + let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") }; + let hue = hsb(&g.gradient.stops[0].color)[0]; + assert!(!(1.0..359.0).contains(&hue), "the new gradient's white stop has hue 0°, not 120°: {hue}"); +} + /// The remembered HSB holds only while the color displays as it did when picked: in another /// working RGB space the wheel shows the color's own HSB there. #[test] From 0c6e971371192baa430dccdf1569626362a62cd7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:41:28 -0800 Subject: [PATCH 32/33] i18n: Color Wheel rows in Georgian, Turkish, Hindi and Bosnian The four complete catalogs get the panel's five rows. --- crates/ui-egui/src/i18n/bs.tsv | 5 +++++ crates/ui-egui/src/i18n/hi.tsv | 5 +++++ crates/ui-egui/src/i18n/ka.tsv | 5 +++++ crates/ui-egui/src/i18n/tr.tsv | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b7ed1bbbe..a988ad207 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -696,6 +696,11 @@ Color Boja Color Guide Vodič za boje Color Themes Teme boja + Color Wheel Kotač boja + Hue ring Prsten nijanse + Saturation and brightness Zasićenje i svjetlina + Color field Polje boje + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS svojstva Document Info Informacije o dokumentu Flattener Preview Pregled spljoštavanja diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index f999c8d31..e169f8b07 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -867,6 +867,11 @@ antiAlias Sharp शार्प Color Table (4 colors) रंग तालिका (4 रंग) Color Theme रंग थीम Color Themes रंग थीम्स + Color Wheel कलर व्हील + Hue ring ह्यू रिंग + Saturation and brightness सैचुरेशन और ब्राइटनेस + Color field कलर फ़ील्ड + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: रंग का प्रकार: Color wheel कलर व्हील Color: रंग: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index d9c7e32f4..aecef4f26 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -606,6 +606,11 @@ Color ფერი Color Guide ფერის გზამკვლევი Color Themes ფერის თემები + Color Wheel ფერთა წრე + Hue ring ფერის ტონის რგოლი + Saturation and brightness გაჯერება და სიკაშკაშე + Color field ფერის ველი + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} CSS Properties CSS თვისებები Document Info დოკუმენტის ინფორმაცია Flattener Preview გაბრტყელების წინასწარი ნახვა diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a9c500eda..23dd0feee 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -547,6 +547,11 @@ font-technology Variable Değişken Color Table ({n} colors) Renk Tablosu ({n} renkleri) Color Theme Renk Teması Color Themes Renk Temaları + Color Wheel Renk Tekerleği + Hue ring Renk tonu halkası + Saturation and brightness Doygunluk ve parlaklık + Color field Renk seçim alanı + {name1} {value1}, {name2} {value2} {name1} {value1}, {name2} {value2} Color Type: Renk Türü: Color: Renk: Colorize Renklendir From 6e8e1e213ee3a8c081d2616683223aef135c9a00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 18:47:54 -0800 Subject: [PATCH 33/33] 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};