From a64a5a9911cb7e5bad6e36d751769c3298e33ca3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 14:29:33 +0700 Subject: [PATCH 01/62] =?UTF-8?q?UI:=20Classic=20colour=20scheme=20(User?= =?UTF-8?q?=20Interface=20=E2=80=BA=20Color=20Scheme)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second colour scheme next to Modern: the neutral greys and blue accent of earlier versions, on the same cards, corners and layout. Picked in Preferences › User Interface › Color Scheme (pref uiColorScheme: modern|classic); Accent Color is dimmed while Classic is chosen, which has its own blue. --- crates/engine/src/cmd/prefscmds.rs | 1 + crates/engine/src/lib.rs | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/prefs_dialog.rs | 33 ++++-- crates/ui-egui/src/theme.rs | 178 ++++++++++++++++++++++++++-- 23 files changed, 274 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index c768fd3a4..c5272f591 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -265,6 +265,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ "Brightness", choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")]) ), + p!("uiColorScheme", "User Interface", "", "Color Scheme", choice(&[("modern", "Modern"), ("classic", "Classic")])), p!( "uiAccent", "User Interface", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 293aa6ef6..0d83ac72f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -746,6 +746,9 @@ pub struct Prefs { pub ui_brightness: String, /// User Interface › Accent Color: the colour of what is on, chosen or focused. pub ui_accent: String, + /// User Interface › Color Scheme: "modern" (with the accent above) or "classic" (neutral + /// greys and a blue accent). + pub ui_color_scheme: String, pub canvas_color: String, pub auto_collapse_icon_panels: bool, /// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…); @@ -979,6 +982,7 @@ impl Default for Prefs { scratch_secondary: s("None"), ui_brightness: s("mediumDark"), ui_accent: s("coral"), + ui_color_scheme: s("modern"), canvas_color: s("matchUi"), auto_collapse_icon_panels: false, tool_group_labels: false, diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bd94ae119..992c2f274 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4920,3 +4920,7 @@ theme Appearance Външен вид Amber Кехлибарен Coral Коралов Violet Виолетов + +# User Interface › Color Scheme + Color Scheme Цветова схема + Modern Модерна diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1f762579d..1c682b957 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4938,3 +4938,7 @@ mark-style Japanese Japonské Amber Jantarová Coral Korálová Violet Fialová + +# User Interface › Color Scheme + Color Scheme Barevné schéma + Modern Moderní diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1210ad9b2..27053aa6c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4475,3 +4475,7 @@ theme Appearance Erscheinungsbild Coral Koralle Violet Violett Pink Pink + +# User Interface › Color Scheme + Color Scheme Farbschema + Modern Modern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1de2b99f4..9a9982b06 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4890,3 +4890,7 @@ effect Points Σημεία Amber Κεχριμπαρί Coral Κοραλλί Violet Βιολετί + +# User Interface › Color Scheme + Color Scheme Χρωματικός συνδυασμός + Modern Σύγχρονο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8214cacf5..a45b685d8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4880,3 +4880,7 @@ mark-style Japanese Japonesas Amber Ámbar Coral Coral Violet Violeta + +# User Interface › Color Scheme + Color Scheme Esquema de color + Modern Moderno diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 378b5b371..337f9f28d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4902,3 +4902,7 @@ mark-style Japanese Japanilainen Amber Meripihka Coral Koralli Violet Violetti + +# User Interface › Color Scheme + Color Scheme Värimalli + Modern Moderni diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 0e8200e29..61524e0bc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4937,3 +4937,7 @@ theme Appearance Apparence Amber Ambre Coral Corail Violet Violet + +# User Interface › Color Scheme + Color Scheme Jeu de couleurs + Modern Moderne diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d492f0f20..ad0389479 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4905,3 +4905,7 @@ imageTrace Outline Körvonal Amber Borostyán Coral Korall Violet Ibolya + +# User Interface › Color Scheme + Color Scheme Színséma + Modern Modern diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a71ab4995..b42874235 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4515,3 +4515,7 @@ lighting Light Luce Coral Corallo Violet Viola Pink Rosa + +# User Interface › Color Scheme + Color Scheme Combinazione colori + Modern Moderno diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e8dfb4b0d..147fbd24f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4556,3 +4556,7 @@ lighting Light ライト Amber アンバー Violet バイオレット Pink ピンク + +# User Interface › Color Scheme + Color Scheme 配色 + Modern モダン diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7c370810f..b30b66865 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4479,3 +4479,7 @@ lighting Light 광원 Coral 코럴 Violet 보라 Pink 분홍 + +# User Interface › Color Scheme + Color Scheme 색 구성표 + Modern 모던 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ab0bd637d..88db75cd0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4783,3 +4783,7 @@ mark-style Japanese Japońskie Coral Koralowy Violet Fioletowy Pink Różowy + +# User Interface › Color Scheme + Color Scheme Schemat kolorów + Modern Nowoczesny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce5f05af1..1f643a993 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4855,3 +4855,7 @@ mark-style Japanese Japonesas Amber Âmbar Coral Coral Violet Violeta + +# User Interface › Color Scheme + Color Scheme Esquema de cores + Modern Moderno diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 281ea9b6d..ca49f5039 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4511,3 +4511,7 @@ lighting Light Свет Coral Коралловый Violet Фиолетовый Pink Розовый + +# User Interface › Color Scheme + Color Scheme Цветовая схема + Modern Современная diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 37c1cbc17..ce55a52e8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4895,3 +4895,7 @@ theme Appearance Utseende Amber Bärnsten Coral Korall Violet Violett + +# User Interface › Color Scheme + Color Scheme Färgschema + Modern Modern diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0db34e51d..18d426428 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4495,3 +4495,7 @@ lighting Light Світло Coral Кораловий Violet Фіолетовий Pink Рожевий + +# User Interface › Color Scheme + Color Scheme Колірна схема + Modern Сучасна diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c35688cd8..eedd4f530 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4495,3 +4495,7 @@ theme Appearance Giao diện Coral San hô Violet Tím Pink Hồng + +# User Interface › Color Scheme + Color Scheme Bảng phối màu + Modern Hiện đại diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4778c5f3a..188682daa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4046,3 +4046,7 @@ lighting Light 光源 Amber 琥珀色 Coral 珊瑚色 Violet 紫罗兰色 + +# User Interface › Color Scheme + Color Scheme 配色方案 + Modern 现代 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 79a420819..c7d32c908 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3834,3 +3834,7 @@ lighting Light 光源 Coral 珊瑚色 Violet 紫羅蘭色 Pink 粉紅色 + +# User Interface › Color Scheme + Color Scheme 配色方案 + Modern 現代 diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 43c3f93c9..690c2a36f 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -9,7 +9,7 @@ use vectorcraft_engine::Prefs; use vectorcraft_engine::cmd::prefscmds::{GPU_PREFERENCES, PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, PrefKind}; use crate::state::Dialog; -use crate::theme::{self, Accent, Brightness, Tokens}; +use crate::theme::{self, Accent, Brightness, Scheme, Tokens}; use crate::{VectorcraftApp, widgets}; /// UI-only fields shown in the dialog (backed by `UiState`, not engine prefs). @@ -82,6 +82,7 @@ pub fn restore(app: &mut VectorcraftApp) { struct Applied { brightness: Brightness, accent: Accent, + scheme: Scheme, /// User Interface › Canvas Color, unless it matches the interface brightness. canvas: Option, threads: i32, @@ -113,6 +114,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { let want = Applied { brightness: app.ui.brightness, accent: Accent::parse(&p.ui_accent).unwrap_or_default(), + scheme: Scheme::parse(&p.ui_color_scheme).unwrap_or_default(), canvas: canvas_color(&p.canvas_color), threads: p.render_threads, tool_tips: p.show_tool_tips, @@ -122,7 +124,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { let id = egui::Id::new("dc-applied-prefs"); let prev: Option = ctx.data(|d| d.get_temp::>(id)).flatten(); if prev != Some(want) { - theme::apply_with(ctx, want.brightness, want.accent); + theme::apply_with(ctx, want.brightness, want.accent, want.scheme); // General › Show Tool Tips: off, no button or field shows its tool tip (they never come // due), whichever widget asks for one. let delay = if want.tool_tips { egui::style::Interaction::default().tooltip_delay } else { f32::INFINITY }; @@ -311,15 +313,13 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { } // Linux only: the other platforms have a single display server. PrefKind::Choice(_) if sp.key == "linuxDisplayServer" && !cfg!(target_os = "linux") => {} + // The Classic colour scheme has its own accent: dimmed while it is chosen. + PrefKind::Choice(opts) if sp.key == "uiAccent" => { + let modern = Scheme::parse(&d.str("uiColorScheme")).unwrap_or_default() == Scheme::Modern; + ui.add_enabled_ui(modern, |ui| choice_row(ui, d, sp.key, sp.label, opts, &v)); + } PrefKind::Choice(opts) => { - labeled(ui, sp.label, |ui| { - let cur = v.as_str().unwrap_or(""); - let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur); - let labels: Vec<&str> = opts.iter().map(|o| o.1).collect(); - if let Some(i) = widgets::dropdown(ui, sp.key, cur_label, &labels, 260.0) { - d.fields.insert(sp.key.into(), json!(opts[i].0)); - } - }); + choice_row(ui, d, sp.key, sp.label, opts, &v); if sp.key == "gpuPreference" || sp.key == "linuxDisplayServer" { // The window's graphics device and display server 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)); @@ -367,6 +367,19 @@ fn picas_in_use(d: &Dialog) -> bool { ["unitsGeneral", "unitsStroke", "unitsType", "unitsAsianType"].iter().any(|k| d.str(k) == vectorcraft_doc::Unit::Picas.key()) } +fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) { + labeled(ui, label, |ui| { + let cur = v.as_str().unwrap_or(""); + let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur); + let labels: Vec<&str> = opts.iter().map(|o| o.1).collect(); + if let Some(i) = widgets::dropdown(ui, key, cur_label, &labels, 260.0) + && let Some(o) = opts.get(i) + { + d.fields.insert(key.into(), json!(o.0)); + } + }); +} + fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { // The app's outlined checkbox (it translates `label`): egui's own has no border at rest. let on = d.bool(key); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c5d947c1d..1f57885c6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -85,6 +85,29 @@ impl Accent { } } +/// User Interface › Color Scheme: Modern (the default: slate greys and the chosen accent) or +/// Classic (the neutral greys and blue accent of earlier versions). Both keep the same layout, +/// cards and corners; only the colours differ. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Scheme { + #[default] + Modern, + Classic, +} + +impl Scheme { + pub const ALL: [Scheme; 2] = [Scheme::Modern, Scheme::Classic]; + pub fn id(self) -> &'static str { + match self { + Scheme::Modern => "modern", + Scheme::Classic => "classic", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s)) + } +} + /// All colours and metrics the UI uses. Never hard-code a colour in a widget. #[derive(Clone, Copy, Debug)] pub struct Tokens { @@ -280,6 +303,129 @@ impl Tokens { } } + /// The Classic colour scheme at brightness `b`: neutral greys with a blue accent, a dark + /// pressed tool and blue-grey selected rows, on the Modern metrics (corners, sizes). + pub fn classic(b: Brightness) -> Self { + let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8); + let base = Tokens { + app_bar: hex(0x262626), + panel: hex(0x323232), + panel_darker: hex(0x282828), + input: hex(0x1f1f1f), + input_border: hex(0x464646), + border: hex(0x1b1b1b), + divider: hex(0x3f3f3f), + text: hex(0xd8d8d8), + text_dim: hex(0xa8a8a8), + text_disabled: hex(0x6e6e6e), + icon: hex(0xcfcfcf), + hover: hex(0x444444), + tool_active: hex(0x1d1d1d), + accent: hex(0x378ef0), + accent_strong: hex(0x1473e6), + row_selected: hex(0x484b50), + pasteboard: hex(0x1e1e1e), + ruler: hex(0x2f2f2f), + ruler_tick: hex(0x8a8a8a), + measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235), + button: hex(0x444444), + text_strong: hex(0xf5f5f5), + tab_strip: hex(0x282828), + button_border: hex(0x6a6a6a), + flyout_active: hex(0x54b2f7), + ..Tokens::for_brightness(Brightness::Dark) + }; + match b { + Brightness::Dark => base, + Brightness::MediumDark => Tokens { + // A shade below the panels, so the cards stand out from the window behind them. + app_bar: hex(0x464646), + panel: hex(0x535353), + panel_darker: hex(0x424242), + tab_strip: hex(0x424242), + input: hex(0x454545), + input_border: hex(0x5f5f5f), + border: hex(0x383838), + divider: hex(0x4b4b4b), + text: hex(0xd1d1d1), + text_strong: hex(0xffffff), + text_dim: hex(0xb0b0b0), + text_disabled: hex(0x7a7a7a), + icon: hex(0xc2c2c2), + hover: hex(0x606060), + tool_active: hex(0x303030), + row_selected: hex(0x52677c), + pasteboard: hex(0x606060), + ruler: hex(0x333333), + button: hex(0x535353), + button_border: hex(0x747474), + ..base + }, + Brightness::MediumLight => Tokens { + dark: false, + app_bar: hex(0xa9a9a9), + panel: hex(0xb8b8b8), + panel_darker: hex(0xaaaaaa), + tab_strip: hex(0xa6a6a6), + input: hex(0xd6d6d6), + input_border: hex(0x8c8c8c), + border: hex(0x969696), + divider: hex(0x9e9e9e), + text: hex(0x1f1f1f), + text_strong: hex(0x000000), + text_dim: hex(0x3c3c3c), + text_disabled: hex(0x7c7c7c), + icon: hex(0x2a2a2a), + hover: hex(0xc8c8c8), + tool_active: hex(0x969696), + row_selected: hex(0x9fb4d6), + pasteboard: hex(0xa0a0a0), + ruler: hex(0xc2c2c2), + ruler_tick: hex(0x4a4a4a), + measure_bg: Color32::from_rgba_unmultiplied(184, 184, 184, 235), + button: hex(0xcacaca), + button_border: hex(0x7a7a7a), + flyout_active: hex(0x1473e6), + ..base + }, + Brightness::Light => Tokens { + dark: false, + app_bar: hex(0xe4e4e4), + panel: hex(0xf0f0f0), + panel_darker: hex(0xe2e2e2), + tab_strip: hex(0xdddddd), + input: hex(0xffffff), + input_border: hex(0xb4b4b4), + border: hex(0xcfcfcf), + divider: hex(0xd6d6d6), + text: hex(0x1f1f1f), + text_strong: hex(0x000000), + text_dim: hex(0x4b4b4b), + text_disabled: hex(0x9a9a9a), + icon: hex(0x303030), + hover: hex(0xdddddd), + tool_active: hex(0xcdcdcd), + row_selected: hex(0xc8daf7), + pasteboard: hex(0xdcdcdc), + ruler: hex(0xeaeaea), + ruler_tick: hex(0x5a5a5a), + measure_bg: Color32::from_rgba_unmultiplied(240, 240, 240, 235), + button: hex(0xe0e0e0), + button_border: hex(0x9a9a9a), + flyout_active: hex(0x1473e6), + ..base + }, + } + } + + /// The tokens for `b` in `scheme`; Modern takes `accent`, Classic has its own blue. + pub fn for_scheme(scheme: Scheme, b: Brightness, accent: Accent) -> Self { + match scheme { + Scheme::Modern => Self::for_brightness(b).with_accent(accent), + Scheme::Classic => Self::classic(b), + } + } + /// These tokens with `accent`: the accent, buttons filled with it, the active item in flyouts and /// selected rows (the panel tinted towards it). pub fn with_accent(self, accent: Accent) -> Self { @@ -400,12 +546,12 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) { /// Apply tokens to egui's global style. pub fn apply(ctx: &egui::Context, b: Brightness) { - apply_with(ctx, b, Accent::default()); + apply_with(ctx, b, Accent::default(), Scheme::default()); } -/// [`apply`] with an accent colour. -pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent) { - let t = Tokens::for_brightness(b).with_accent(accent); +/// [`apply`] with an accent colour and colour scheme. +pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent, scheme: Scheme) { + let t = Tokens::for_scheme(scheme, b, accent); ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; @@ -467,10 +613,26 @@ mod tests { /// in every brightness. #[test] fn slider_rails_show_on_every_panel() { - for b in [Brightness::Dark, Brightness::MediumDark, Brightness::MediumLight, Brightness::Light] { - let t = Tokens::for_brightness(b); - let gap = (i32::from(t.button_border.r()) - i32::from(t.panel.r())).abs(); - assert!(gap >= 24, "{b:?}: rail {:?} on panel {:?}", t.button_border, t.panel); + for b in Brightness::ALL { + for scheme in Scheme::ALL { + let t = Tokens::for_scheme(scheme, b, Accent::default()); + let gap = (i32::from(t.button_border.r()) - i32::from(t.panel.r())).abs(); + assert!(gap >= 24, "{scheme:?} {b:?}: rail {:?} on panel {:?}", t.button_border, t.panel); + } + } + } + + /// Classic keeps the Modern corners and the cards' frame, and has its own blue accent + /// whatever accent is chosen. + #[test] + fn classic_scheme_keeps_the_corners_and_its_blue() { + for b in Brightness::ALL { + let (modern, classic) = (Tokens::for_brightness(b), Tokens::for_scheme(Scheme::Classic, b, Accent::Pink)); + assert_eq!(classic.radius, modern.radius); + assert_eq!(classic.dark, modern.dark, "{b:?}"); + assert_ne!(classic.app_bar, classic.panel, "{b:?}: the cards need a frame"); + assert_eq!(classic.accent, Color32::from_rgb(0x37, 0x8e, 0xf0)); + assert_eq!(Scheme::parse(Scheme::Classic.id()), Some(Scheme::Classic)); } } } From c5e38a309ca8d5e332ed7f7197b812504f9aeae9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:20:27 +0200 Subject: [PATCH 02/62] Contributors: add my name for the About window --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index e70db4a25..b30eb0fd2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -115,3 +115,8 @@ sync_github_name = false real_name = "Kamil Kwiatkowski" display_name = "Kamil Kwiatkowski" sync_github_name = false + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From 54e43d0a9743729d08b805944c74514681958752 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 16 Oct 2026 05:53:18 +0200 Subject: [PATCH 03/62] i18n(sv): review of the rows drafted for the strings main added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Font Style:" in Dimension Tool Options is Teckensnittsstil: Teckenstil is already Character Style. - The drawing-scale help reads "1:10 visar 1 enhet på sidan som 10". - The layered-file fallback message and "Check an artboard to save" read more naturally. - "File › Place…" names the menu as the menus do (Fil, not Arkiv). - The rows are reviewed, so the "machine-drafted; review welcome" note above them goes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/sv.tsv | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce55a52e8..16fb7f533 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1017,7 +1017,7 @@ File Info Dialog Dialogrutan Filinformation File Info… Filinformation… File Name: Filnamn: - File › Place… brings in an image, linked or embedded. Arkiv › Placera… hämtar in en bild, länkad eller inbäddad. + File › Place… brings in an image, linked or embedded. Fil › Placera… hämtar in en bild, länkad eller inbäddad. Files Filer Files of a PDF standard can't be password-protected. Filer enligt en PDF-standard kan inte lösenordsskyddas. Files of this standard tag their colours with ICC profiles. Filer enligt den här standarden märker sina färger med ICC-profiler. @@ -4784,7 +4784,6 @@ theme Appearance Utseende Softness Mjukhet Ambient Light Omgivande ljus Light behind the object Ljus bakom objektet -# Strings added upstream after this catalog was written (machine-drafted; review welcome). cancels | Double-click the tool for options avbryter | Dubbelklicka på verktyget för alternativ out from a highlighted edge to place its dimension | utåt från en framhävd kant för att placera dess mått | A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. En äldre .ai-fil rymmer en rityta: varje rityta du markerar sparas som en separat fil. @@ -4792,7 +4791,7 @@ theme Appearance Utseende Batch… Batch… By Layer Efter lager By Object Efter objekt - Check an artboard to save. Markera en rityta att spara. + Check an artboard to save. Markera en rityta som ska sparas. Clear All Rensa alla Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Stäng de cirkulära ändarna på öppna profiler. Partiella svep behåller sina snittsidor öppna. Color Panel Options Alternativ för panelen Färg @@ -4803,14 +4802,14 @@ theme Appearance Utseende Dimension Mått Dimension Tool Måttverktyg Dimension Tool Options Alternativ för måttverktyget - Drawing : real size — 1:10 labels 1 unit on the page as 10. Ritning : verklig storlek — 1:10 märker 1 enhet på sidan som 10. + Drawing : real size — 1:10 labels 1 unit on the page as 10. Ritning : verklig storlek — 1:10 visar 1 enhet på sidan som 10. Editable Redigerbar Export Data Sets… Exportera datauppsättningar… Filter Filter Filter Layers Panel Rows Filtrera rader i panelen Lager Flatten Platta till Font Family: Teckensnittsfamilj: - Font Style: Teckenstil: + Font Style: Teckensnittsstil: Groups Grupper Hidden Dold Hidden Layer Dolt lager @@ -4883,7 +4882,7 @@ theme Appearance Utseende @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" placerar själva övertoningen: utelämna `start` och `end` (de placerar den redan i dokumentkoordinater) @msg the Color Picker is not open färgväljaren är inte öppen @msg the action has no steps åtgärden har inga steg -@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer filens lager lästes inte ({_1}): den öppnas som sin sida, i ett lager +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer filens lager lästes inte ({_1}): den hämtas in som en sida, i ett enda lager @msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part filens lager lästes inte från dess redigeringsdata ({_1}): de kommer från dess PDF-del @msg the screen color sampler is already open färghämtningen från skärmen är redan öppen @msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS det här är en PostScript-fil (en .ai sparad i ett äldre format eller utan PDF-kompatibilitet, eller en EPS-fil), inte en PDF: Fil › Öppna och Fil › Placera läser den som EPS From f9092edfe3c507426605e3e952679aa7369366eb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 16 Oct 2026 00:01:42 -0400 Subject: [PATCH 04/62] ci: standardize FreeBSD workflow on shared FreeBSD 15.1 action --- .github/workflows/freebsd.yml | 45 +---------------------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f58f270b4..37cebf6c3 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -36,47 +36,4 @@ jobs: with: require-craft-fonts: true craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - - package: - name: FreeBSD 14 (package) - # The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies) - # and on demand, not on every push to main. - if: github.event_name != 'push' - runs-on: ubuntu-latest - timeout-minutes: 150 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Japanese, Chinese, Arabic and Thai fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM). - # Bump the commit deliberately; see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - path: craft-fonts - - name: Package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 - with: - release: "14.3" - usesh: true - mem: 12288 - prepare: | - pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM. - # The craft-fonts checkout is synced into the VM with the workspace. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4 - bash packaging/freebsd/package.sh - # Every part the package must have, the craft-fonts licences included. - tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt - for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do - grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; } - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: freebsd-package - path: dist/release/* - if-no-files-found: error + run-package-check: true From e9edc49977335021ca1e5eff870b76f284821616 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:07 -0400 Subject: [PATCH 05/62] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 3b00334c41767ee524fb252d2ba42e1fb01f2e87 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 04:38:48 +0200 Subject: [PATCH 06/62] Graph Type: the graph kind is a dropdown of the nine types (fixes #2175) --- crates/ui-egui/src/dialogs/command.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 7b283c6ca..59309c5e9 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -1,6 +1,8 @@ //! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`, //! headed `__label`) and runs it on OK, with the fixed ones in `__params` (not shown) added. +use std::sync::LazyLock; + use serde_json::Value; use super::{DialogSpec, form}; @@ -40,6 +42,7 @@ fn choices(command: &str, key: &str) -> Option { ("text.areaOptions", "fit") => Some(AREA_FIT), ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), + ("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()), ("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS), ("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH), ("graph.setType", "pieLegend") => Some(PIE_LEGEND), @@ -62,6 +65,10 @@ const FIRST_BASELINE: form::Choices = /// Area Type Options › Align (vertical alignment of the lines in each row/column). const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")]; +/// Graph Type › Type: the nine graph kinds, in the Graph tool group's order. +static GRAPH_KINDS: LazyLock> = + LazyLock::new(|| vectorcraft_doc::GraphKind::ALL.iter().map(|k| (k.label(), k.id())).collect()); + /// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is). const GRAPH_VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")]; @@ -146,4 +153,23 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); } + + /// Graph Type › Type is a dropdown of the nine graph kinds (#2175), and OK changes the graph's + /// type and keeps its data. + #[test] + fn graph_type_is_a_dropdown_of_the_graph_kinds() { + let kinds: Vec<(&str, &str)> = vectorcraft_doc::GraphKind::ALL.iter().map(|k| (k.label(), k.id())).collect(); + assert_eq!(choices("graph.setType", "type").unwrap().to_vec(), kinds); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150, "csv": ",a,b\nQ1,1,2\nQ2,3,4"})).unwrap(); + let data = app.session.execute("graph.setData", &json!({})).unwrap(); + assert_eq!(data["rows"], json!([[1.0, 2.0], [3.0, 4.0]])); + crate::menus::graph_dialog(&mut app, "graph.setType").unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "column"); + app.ui.dialog.as_mut().unwrap().fields.insert("type".into(), json!("stackedBar")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["type"], "stackedBar"); + assert_eq!(app.session.execute("graph.setData", &json!({})).unwrap(), data); + } } From f14f8d8eb7e276a29732b8f821fa4918d781e8bc Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:19:02 +0300 Subject: [PATCH 07/62] Font menu: the scroll bar no longer covers the favourite stars (#2277) The family list scrolled with egui's floating scroll bar, which is drawn over the rows' right ends, where each family's star is: with the pointer there the bar widened over the star and a click went to the bar. The list now takes the scroll bar the menus got for the same reason (#2086), slim and in a column of its own; `widgets::list_scroll_style` holds it for both. --- crates/ui-egui/src/font_menu.rs | 42 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 8 ++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index f1fcde745..bd723456e 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -372,6 +372,9 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp let mut toggled: Option = None; let mut starred: Option = None; ui.spacing_mut().item_spacing.y = 0.0; + // The scroll bar beside the rows, not over them: a floating one covered the stars at their + // right ends, so a favourite was hard to click (#2277). + ui.spacing_mut().scroll = widgets::list_scroll_style(); let shown = area.show_rows(ui, row_h, rows.len(), |ui, range| { for i in range { let Some(row) = rows.get(i) else { continue }; @@ -775,6 +778,45 @@ mod tests { assert_eq!(super::script_label(3), "Thai"); } + /// #2277: the list's scroll bar has a column of its own, so the stars at the rows' right ends + /// stay clear of it. + #[test] + fn the_scroll_bar_leaves_the_stars_clear() { + // Every family starred, so each row draws its star. + let all = vectorcraft_text::FontDb::global().families(); + let families = all.len(); + let ctx = egui::Context::default(); + let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); + let mut st = super::MenuState::default(); + let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; + let mut stars = vec![]; + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); + super::list(&mut child, egui::Id::new("fonts"), &mut st, "", None, look); + }); + out.textures_delta.clear(); + stars = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + // The rows' stars, not the ★ filter button at the top left. + egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()), + _ => None, + }) + .collect(); + } + assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); + // The rows don't fill the list without enough families to scroll, and then there is no bar. + if families as f32 * look.row > area.height() { + let bar = super::widgets::list_scroll_style(); + let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; + for right in stars { + assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar"); + } + } + } + use super::{Row, remap_highlight}; fn family(name: &str) -> Row { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4ed3caa60..4d0f2774e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -861,6 +861,12 @@ pub fn menu_room(floor: f32, top: f32) -> f32 { (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) } +/// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never +/// covers what sits at their right end (shortcuts, submenu arrows, a font's favourite star). +pub fn list_scroll_style() -> egui::style::ScrollStyle { + egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } +} + /// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and /// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on /// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the @@ -869,7 +875,7 @@ pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::new("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); let room = menu_room(floor, ui.next_widget_position().y); ui.scope(|ui| { - ui.spacing_mut().scroll = egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() }; + ui.spacing_mut().scroll = list_scroll_style(); // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to // its items when they need less. egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner From ee6cd6de555355f62c53f9f04ca0b9b92a32700d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:01:34 +0000 Subject: [PATCH 08/62] Keep paragraph styles aligned in Find and Replace with line breaks Found while testing crates/engine/src/cmd/typemenu.rs:668 (find_replace / edit_runs) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/typemenu.rs | 41 ++++++++++++++++----- crates/engine/tests/find_replace_paras.rs | 44 +++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/find_replace_paras.rs diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index b18b97bff..a21d09263 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -665,16 +665,39 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if total == 0 { return Ok(json!({ "count": 0 })); } - edit_runs::<()>(s, "Replace", &ids, |text, _| { - let mut out = String::with_capacity(text.len()); - let mut last = 0; - for (a, b) in find_all(text, &find, mc, ww) { - out.push_str(&text[last..a]); - out.push_str(&rep); - last = b; + super::overprint::edit_counted(s, "Replace", |d| { + let mut changed = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + // Matches as (run, start, end) with offsets inside the run. Inline graphics are art, not + // characters to rewrite. + let mut hits = vec![]; + let mut base = 0; + for (ri, r) in t.runs.iter().enumerate() { + if r.inline.is_none() { + for (a, b) in find_all(&r.text, &find, mc, ww) { + if r.text.get(a..b) != Some(rep.as_str()) { + hits.push((ri, base + a, base + b, a, b)); + } + } + } + base += r.text.len(); + } + if hits.is_empty() { + continue; + } + // Last match first, so the offsets of earlier ones stay valid. The paragraph styles follow + // each edit, as `text.editRange` does. + for (ri, ta, tb, a, b) in hits.into_iter().rev() { + t.splice_paras(ta, tb, &rep); + if let Some(r) = t.runs.get_mut(ri) { + r.text.replace_range(a..b, &rep); + } + } + changed += 1; + refresh_bounds(t); } - out.push_str(&text[last..]); - out + Ok(changed) })?; Ok(json!({ "count": total })) } diff --git a/crates/engine/tests/find_replace_paras.rs b/crates/engine/tests/find_replace_paras.rs new file mode 100644 index 000000000..b511ddf08 --- /dev/null +++ b/crates/engine/tests/find_replace_paras.rs @@ -0,0 +1,44 @@ +//! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace +//! string holds a line break, as `text.editRange` does. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_doc::{Justify, NodeId, NodeKind}; +use vectorcraft_testkit::fixtures::{self, exec, id_of}; + +/// "A\nB\nC" with B centred and C right-aligned. +fn setup() -> (vectorcraft_engine::Session, NodeId) { + let mut s = fixtures::session_with(400.0, 300.0); + let id = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "A\nB\nC", "size": 20}))); + exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 2, "end": 3, "justify": "center"})); + exec(&mut s, "text.setStyle", json!({"id": id.0, "start": 4, "end": 5, "justify": "right"})); + exec(&mut s, "select.none", json!({})); + (s, id) +} + +fn state(s: &vectorcraft_engine::Session, id: NodeId) -> (String, Vec) { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (t.plain_text(), t.paragraph_styles().iter().map(|p| p.justify).collect()), + _ => panic!("not text"), + } +} + +#[test] +fn replacement_adding_a_line_break_keeps_later_paragraph_styles() { + let (mut s, id) = setup(); + assert_eq!(state(&s, id).1, [Justify::Auto, Justify::Center, Justify::Right]); + exec(&mut s, "edit.findReplace", json!({"find": "A", "replace": "A\nA2", "ids": [id.0]})); + let (text, styles) = state(&s, id); + assert_eq!(text, "A\nA2\nB\nC"); + assert_eq!(styles, [Justify::Auto, Justify::Auto, Justify::Center, Justify::Right]); +} + +#[test] +fn find_text_with_a_line_break_merges_paragraphs_like_edit_range() { + let (mut s, id) = setup(); + exec(&mut s, "edit.findReplace", json!({"find": "A\nB", "replace": "AB", "ids": [id.0]})); + let (text, styles) = state(&s, id); + assert_eq!(text, "AB\nC"); + assert_eq!(styles, [Justify::Auto, Justify::Right]); +} From e51019086795570ce0923b1dbd4157c83767fbc4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 06:32:18 +0200 Subject: [PATCH 09/62] Keep Layers panel selection inside the isolated group Fixes #2286 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layer.rs | 10 +++++++++- crates/engine/src/tests_menucmds.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 116fd9e0a..7da86b806 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -412,6 +412,9 @@ fn target(s: &mut Session, p: &Value) -> Result { if !st.doc.is_editable(id) { return Err(bad(C, "the object is hidden or locked")); } + if !in_isolation(&st.doc, st.isolation, id) { + return Err(bad(C, "the object is outside the isolated group")); + } let layer = n.is_layer(); let rows = st.layer_rows.clone(); if p.get("add").and_then(Value::as_bool).unwrap_or(false) { @@ -645,6 +648,11 @@ fn collect(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": lid.0 })) } +/// Whether `id` lies inside the isolated group or layer (always, outside isolation mode). +fn in_isolation(doc: &Document, isolation: Option, id: NodeId) -> bool { + isolation.is_none_or(|iso| iso != id && doc.ancestry(id).is_some_and(|a| a.contains(&iso))) +} + /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.selectAll"; @@ -652,7 +660,7 @@ fn select_art(s: &mut Session, p: &Value) -> Result { let add = bool_or(p, "add", false); let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; - let ids: Vec = if !st.doc.is_editable(id) { + let ids: Vec = if !st.doc.is_editable(id) || !in_isolation(&st.doc, st.isolation, id) { vec![] } else if n.is_layer() { n.layer_art(true) diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 7855d884f..cbf09ead1 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -1014,6 +1014,35 @@ fn select_menu_commands_stay_inside_the_isolated_group() { assert_eq!(selected(&s), vec![a, b, text_inside]); } +/// In isolation mode the Layers panel's target circle and selection column can't select an object +/// outside the isolated group, so it can't be moved either (#2286). +#[test] +fn layers_panel_selects_nothing_outside_the_isolated_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + let b = rect(&mut s, 100.0, 0.0, 50.0, 50.0); + let outside = rect(&mut s, 300.0, 0.0, 50.0, 50.0); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + let before = bounds(&s, outside); + let _ = s.execute("layer.target", &json!({"id": outside.0})); + assert!(!selected(&s).contains(&outside)); + let _ = s.execute("layer.target", &json!({"id": outside.0, "add": true})); + assert!(!selected(&s).contains(&outside)); + let _ = s.execute("layer.selectAll", &json!({"id": outside.0})); + assert!(!selected(&s).contains(&outside)); + let _ = s.execute("layer.selectAll", &json!({"id": outside.0, "add": true})); + assert!(!selected(&s).contains(&outside)); + let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0})); + assert_eq!(bounds(&s, outside), before); + // Objects inside the group can still be targeted and selected. + s.execute("layer.target", &json!({"id": a.0})).unwrap(); + assert_eq!(selected(&s), vec![a]); + s.execute("layer.selectAll", &json!({"id": b.0})).unwrap(); + assert_eq!(selected(&s), vec![b]); +} + /// In isolation mode a recalled selection leaves out the objects outside the isolated group, and /// Next Object Above selects nothing outside it. #[test] From d838af024ba1a21096b2dcd2a6e030b06a74d122 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:58:24 +0300 Subject: [PATCH 10/62] MCP: command listings take query, menu and brief (#2173) The command catalogue is about 340,000 characters, more than many MCP clients show, and `query` (the name agents reach for first) was refused as an unknown argument. command_list and list_commands now take `query` as another name for `filter`, `menu` to keep the commands under a menu path ("Object > Path"), and `brief` to leave out each command's params text, which is most of the catalogue; the params of one command are a filtered listing away. --- crates/mcp/src/tests_conventions.rs | 29 ++++++++++++++++ crates/mcp/src/tools.rs | 54 ++++++++++++++++++++++------- docs/mcp.md | 4 +-- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/mcp/src/tests_conventions.rs b/crates/mcp/src/tests_conventions.rs index 4bca66943..c5478fa9f 100644 --- a/crates/mcp/src/tests_conventions.rs +++ b/crates/mcp/src/tests_conventions.rs @@ -40,6 +40,35 @@ fn conventions_catalog_and_strict_keys() { assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({})); } +/// #2173: the command listings take `query` (as `filter`), a `menu` filter, and `brief`, which +/// leaves out the long params text, so the catalogue fits a client's output limit. +#[test] +fn command_listings_take_query_menu_and_brief() { + let mut s = Server::new(Box::new(Headless::with_document())); + let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::>(); + let by_filter = payload(&call(&mut s, "command_list", json!({"filter":"rectangle"}))); + let by_query = payload(&call(&mut s, "command_list", json!({"query":"rectangle"}))); + assert!(ids(&by_filter).contains(&"shape.rectangle".to_string())); + assert_eq!(by_filter, by_query); + // `menu` keeps the commands under that menu path. + let path_menu = payload(&call(&mut s, "command_list", json!({"menu":"object > path"}))); + assert!(ids(&path_menu).contains(&"object.path.addAnchorPoints".to_string()), "{path_menu}"); + for c in path_menu.as_array().unwrap() { + let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect(); + assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}"); + } + // `brief` drops the params text and nothing else. + let full = payload(&call(&mut s, "command_list", json!({}))); + let brief = payload(&call(&mut s, "command_list", json!({"brief":true}))); + assert_eq!(ids(&full), ids(&brief)); + assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some())); + assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some())); + assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter"); + // The other listing takes them too. + let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""}))); + assert_eq!(ids(&r["commands"]), ids(&by_filter)); +} + #[test] fn conventions_commands_batch_and_bounded_preview() { let mut s = Server::new(Box::new(Headless::with_document())); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1048810d3..d4f81d6a1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,8 +102,11 @@ pub fn tool_definitions() -> Vec { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array.", - obj(json!({"filter": string("Case-insensitive id, label or menu substring"), "enabled_only": {"type":"boolean"}}), &[]), + "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief.", + obj( + json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), + &[], + ), true, ), tool( @@ -153,7 +156,7 @@ pub fn tool_definitions() -> Vec { "List commands", "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.", obj( - json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}}), + json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], ), true, @@ -838,25 +841,52 @@ fn image_result(png: &[u8], info: Value) -> ToolResult { } } +/// The `menu` argument of the command listings. +const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; +/// The `brief` argument of the command listings. +const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; + +/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the +/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` +/// text: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { - let needle = a.get("filter").and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let needle = text("filter").or_else(|| text("query")); + let in_menu = text("menu"); let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); + let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false); let Value::Array(list) = all else { return all }; + let menu_path = |c: &Value, sep: &str| { + c.get("menu") + .and_then(Value::as_array) + .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(sep)) + .unwrap_or_default() + .to_lowercase() + }; let hit = |c: &Value| { if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { return false; } + if let Some(m) = &in_menu + && !menu_path(c, " > ").contains(m.as_str()) + { + return false; + } let Some(n) = &needle else { return true }; let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); - let menu = c - .get("menu") - .and_then(Value::as_array) - .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" ")) - .unwrap_or_default() - .to_lowercase(); - field("id").contains(n) || field("label").contains(n) || menu.contains(n) + field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str()) }; - Value::Array(list.into_iter().filter(hit).collect()) + Value::Array( + list.into_iter() + .filter(hit) + .map(|mut c| { + if brief && let Some(o) = c.as_object_mut() { + o.remove("params"); + } + c + }) + .collect(), + ) } fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { diff --git a/docs/mcp.md b/docs/mcp.md index 5ee1fe0d5..f9e0d2886 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -258,7 +258,7 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter`, `enabled_only`; returns the command array | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | | `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` | @@ -300,7 +300,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | Tool | Arguments | Notes | |---|---|---| -| `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. | +| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | | `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | From 80b7aee756828819565a3e4524a8c8aa0f3160a5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 09:06:47 +0200 Subject: [PATCH 11/62] Isolation mode: a path of the isolated compound path moves on its own (fixes #2354) --- crates/engine/src/cmd/edit.rs | 15 ++++++++++++++- crates/engine/src/cmd/object.rs | 18 +++++++++--------- crates/engine/src/tests.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 89946fb09..2c0eff9f4 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -324,9 +324,22 @@ pub(crate) fn selected_roots(s: &Session) -> Result> { Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) } +/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in +/// an isolated compound path its paths are objects of their own, so the hole of a ring moves +/// without the ring. Paint and the rest still go to the compound path, which holds the paint. +pub(crate) fn selected_roots_to_transform(s: &Session) -> Result> { + let st = s.doc()?; + Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation)) +} + /// The top-level objects among `ids` (in paint order): compound-path members stand for their /// compound, and ids whose ancestor is also listed, and layers, are dropped. pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { + roots_within(doc, ids, None) +} + +/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path. +fn roots_within(doc: &Document, ids: Vec, isolation: Option) -> Vec { // Every id's place in the tree, found in one walk (a lookup per id is a walk per id). let paths = doc.index_paths(ids.iter().copied()); // A compound-path member acts as its compound (compounds are one object). @@ -336,7 +349,7 @@ pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { let path = paths.get(&id); let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty()); match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) { - Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => (n.id, Some(up.to_vec())), + Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())), _ => (id, path.cloned()), } }) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 8620ce988..4ce950016 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, No use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; -use super::edit::{duplicate_in, outermost, selected_roots}; +use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform}; use super::opacitymask::percent; use super::*; use crate::{EngineError, LastTransform}; @@ -493,14 +493,14 @@ fn transform(s: &mut Session, p: &Value) -> Result { let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), - None => (selected_roots(s)?, bool_or(p, "anchors", false)), + None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)), }; apply_transform(s, "Transform", ids, m, p, anchors) } fn move_cmd(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.move")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) } @@ -524,7 +524,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result { } return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy })); } - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true) } @@ -534,7 +534,7 @@ fn about(o: Point, a: Affine) -> Affine { fn rotate(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.rotate")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let mut angle = f64_or(p, "angle", 0.0); if bool_or(p, "absolute", false) { @@ -547,7 +547,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { fn scale(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["sy"], "object.scale")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let sx = f64_req(p, "sx", "object.scale")? / 100.0; let sy = f64_or(p, "sy", sx * 100.0) / 100.0; @@ -558,7 +558,7 @@ fn scale(s: &mut Session, p: &Value) -> Result { } fn reflect(s: &mut Session, p: &Value) -> Result { - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let m = match p.get("axis") { Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), @@ -578,7 +578,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result { fn shear(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.shear")?; - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); let vertical = choice(p, "object.shear", "axis", &["horizontal", "vertical"])? == Some("vertical"); @@ -602,7 +602,7 @@ fn transform_again(s: &mut Session, p: &Value) -> Result { }; match last { LastTransform::Matrix(m, options) => { - let ids = selected_roots(s)?; + let ids = selected_roots_to_transform(s)?; apply_transform(s, "Transform Again", ids, m, &merge(options), true) } LastTransform::Each(params) => super::menucmds::transform_each_again(s, &merge(params)), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3c51e3a85..db552a844 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1888,6 +1888,34 @@ fn isolating_an_object_that_doesnt_exist_says_so() { assert!(!e.to_string().contains("type"), "{e}"); } +/// A ring (a compound path with a hole) and its isolated paths. +fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let outer = rect(s, 0.0, 0.0, 40.0, 40.0); + let hole = rect(s, 10.0, 10.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [outer.0, hole.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.isolate", &json!({"id": c.0})).unwrap(); + s.execute("select.set", &json!({"ids": [hole.0]})).unwrap(); + (c, outer, hole) +} + +/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected +/// alone, moves without the ring around it. Outside isolation it still moves its compound path. +#[test] +fn an_isolated_compound_paths_member_moves_alone() { + let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let mut s = session(); + let (_, outer, hole) = isolated_ring(&mut s); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved"); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 45.0), "Move: only the hole"); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [hole.0]})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap(); + assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved"); +} + /// `depth` and `childLimit` slice a summary read; a level that shows fewer children /// than it has reports `childCount`, and invalid values are rejected, not reinterpreted. #[test] From c91405dbea76cc85e6e92bb60f4eac0f4039f1e4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 08:16:32 +0200 Subject: [PATCH 12/62] Window menu: a panel's item hides it while it shows and shows it again where it was (part of #2361) --- .../src/dialogs/tests_screen_assets.rs | 2 +- crates/ui-egui/src/menus.rs | 19 ++++++++- crates/ui-egui/src/panel_docking_tests.rs | 40 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index 9274b5224..60d47b39a 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -136,7 +136,7 @@ fn the_menus_reach_asset_export() { assert_eq!(find("assets.add", "As Single Asset"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); assert_eq!(find("assets.add", "As Multiple Assets"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); let window = entries.iter().find(|e| e.label == "Asset Export").unwrap(); - assert_eq!(window.params, json!({"panel": crate::panels::asset_export::ID})); + assert_eq!(window.params, json!({"panel": crate::panels::asset_export::ID, "toggle": true})); app.run("window.panel", window.params.clone()).unwrap(); assert_eq!(app.ui.open_panel.as_deref(), Some(crate::panels::asset_export::ID)); app.run("assets.add", json!({"multiple": false})).unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ae1a6668c..2ba3ea82f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -325,7 +325,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), - ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"), + ( + "window.panel", + "Show Panel", + "", + "{panel: id, toggle?: bool} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too); toggle (the Window menu) hides a panel that shows, and shows it again where it was", + ), ( "window.panel.layout", "Change Panel Layout", @@ -979,6 +984,16 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va } pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + // The Window menu's panel items toggle like its other checked items (#2361); the panel's + // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. + if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); + if app.ui.open_panel.as_deref() == Some(panel) { + app.ui.open_panel = None; + return Some(Ok(json!({"open": null}))); + } + return Some(app.run("window.panel.close", json!({"panel": panel}))); + } if let Some(result) = crate::panel_docking::command(app, id, p) { return Some(result); } @@ -2157,7 +2172,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { } pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { - let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id })); + let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id, "toggle": true })); vec![ ( // The application menu: About and Discord only. The in-window bar never shows it (File diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 911f5d3dd..c66623e75 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -574,3 +574,43 @@ fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() { "visible native group owns the drop: {target:?}" ); } + +/// Window › Properties hides the panel while it shows, and the next click shows it again where it +/// was (#2361): in the dock as the app starts, floating, and once the layout has been changed. +/// The panel's other ways in (a tool's double-click, Show Properties Panel) only show it. +#[test] +fn the_window_menu_item_hides_a_shown_panel_and_shows_it_again() { + let item = crate::menus::menu_entries(&app()) + .into_iter() + .find(|e| e.path == ["Window"] && e.command.as_deref() == Some("window.panel") && e.params["panel"] == "properties") + .expect("Window › Properties"); + let params = item.params; + let shown = |app: &VectorcraftApp| crate::menus::checked(app, "window.panel", ¶ms) == Some(true); + let in_layout = |app: &VectorcraftApp| app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&"properties".into())); + + let mut app = app(); + assert!(app.ui.docking.is_none() && shown(&app), "the default dock shows Properties"); + let start = legacy(&app); + app.run("window.panel", params.clone()).unwrap(); + assert!(!shown(&app) && !in_layout(&app), "the first click hides it"); + app.run("window.panel", params.clone()).unwrap(); + assert!(shown(&app)); + assert_eq!(app.ui.docking.as_ref(), Some(&start), "the second shows it where it was"); + + app.run("window.panel.move", json!({"panel":"properties", "anchor":"layers", "zone":"top"})).unwrap(); + let moved = app.ui.docking.clone(); + app.run("window.panel", params.clone()).unwrap(); + assert!(!shown(&app) && !in_layout(&app)); + app.run("window.panel", params.clone()).unwrap(); + assert!(shown(&app) && app.ui.docking == moved); + + app.run("window.panel.float", json!({"panel":"properties", "x":50, "y":70, "width":330, "height":410})).unwrap(); + let floating = app.ui.docking.clone(); + app.run("window.panel", params.clone()).unwrap(); + assert!(!shown(&app) && !in_layout(&app), "a floating Properties hides too"); + app.run("window.panel", params.clone()).unwrap(); + assert!(shown(&app) && app.ui.docking == floating); + + app.run("window.panel", json!({"panel":"properties"})).unwrap(); + assert!(shown(&app), "without the menu's toggle the command only shows the panel"); +} From 04806a98bf52b7bb48d3b172c00d8e54956a619a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 20:29:32 -0700 Subject: [PATCH 13/62] .ai import: text on a layer that doesn't print stays on that layer A `.ai` opened from its editing data lost a text object on a layer that shows and has Print off when its page drew no type for it. prune_unseen weighs each text object made from the text document that the page's type doesn't match against the page, and the layers are drawn for that comparison as they print (as_printed), with such layers hidden. Taking one of these objects out never changed the comparison, so each was removed with the note "text objects that the file keeps but its page doesn't draw ... are left out". unmark now carries a print flag next to the shown flag, and a layer with printable off clears it for everything it holds. Only made text objects that show and print are weighed against the page; the others stay on their layer. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/eps/src/import/native.rs | 21 +++++++++++---------- crates/eps/src/import/tests_native.rs | 18 ++++++++++++++++++ docs/mcp.md | 3 ++- docs/target-app-parity.md | 1 + 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index c7f193c2b..465cec773 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1005,7 +1005,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr Err(why) => return Err(why), } } - None => unmark(&mut doc.layers, true, &mut vec![]), + None => unmark(&mut doc.layers, true, true, &mut vec![]), } doc.limit_depth_noting(&mut notes); if small.is_some() { @@ -1047,12 +1047,13 @@ const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't /// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse. const UNSEEN_MARGIN: f64 = 5e-5; -/// Clear the marks in `nodes` and say which marked objects show, by id. -fn unmark(nodes: &mut [Arc], shown: bool, found: &mut Vec<(NodeId, Option)>) { +/// Clear the marks in `nodes` and say which marked objects show and print, by id. +fn unmark(nodes: &mut [Arc], shown: bool, prints: bool, found: &mut Vec<(NodeId, Option)>) { for n in nodes { let shown = shown && n.visible; + let prints = prints && !matches!(n.kind, NodeKind::Layer { printable: false, .. }); if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) { - if shown { + if shown && prints { found.push((n.id, n.visual_bounds())); } let name = (!name.is_empty()).then(|| name.to_string()); @@ -1060,7 +1061,7 @@ fn unmark(nodes: &mut [Arc], shown: bool, found: &mut Vec<(NodeId, Option< } else if !matches!(n.kind, NodeKind::Compound { .. }) && let Some(c) = Arc::make_mut(n).children_mut() { - unmark(c, shown, found); + unmark(c, shown, prints, found); } } } @@ -1080,13 +1081,13 @@ fn take_out(nodes: &mut Vec>, id: NodeId, hide: bool) -> bool { nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide))) } -/// The text objects made from the file's text document that show on the page's area, where the page -/// has no type to match them, and that the layers look no worse without, are not drawn by the app -/// that wrote the file (which paints every shown text object; it keeps the type of what was turned -/// to outlines): they are taken out. How many. +/// The text objects made from the file's text document that show and print on the page's area, +/// where the page has no type to match them, and that the layers look no worse without, are not +/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type +/// of what was turned to outlines): they are taken out. How many. fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize { let mut found = vec![]; - unmark(&mut doc.layers, true, &mut found); + unmark(&mut doc.layers, true, true, &mut found); let rect = frame.rect; if found.is_empty() || found.len() > MAX_WEIGHED { return 0; diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 84217c4b2..b7f9a67fe 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -628,6 +628,24 @@ fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() { assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings); } +/// A text object on a layer that shows and doesn't print, which the page doesn't draw, isn't taken +/// out: it comes in from the text document, on its layer. +#[test] +fn a_shown_text_object_on_a_layer_that_doesnt_print_stays_on_its_layer() { + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + // The layer's options with its fourth flag, printing, off. + let tips = layer("Tips", true, &text_object(0)).replace("1 1 1 1 0 0 1 0 79", "1 1 1 0 0 0 1 0 79"); + let art = native_with_text(&(layer("Art", true, &square(10, 10)) + &tips), &doc); + let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Art", "Tips"], "{w:?}"); + let tips = &d.layers[1]; + assert!(tips.visible && matches!(tips.kind, NodeKind::Layer { printable: false, .. })); + let texts: Vec = text_of(tips).iter().map(|t| t.plain_text()).collect(); + assert_eq!(texts, ["Hi there"], "{w:?}"); + assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); +} + #[test] fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() { // "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them. diff --git a/docs/mcp.md b/docs/mcp.md index f9e0d2886..2581f7744 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -546,7 +546,8 @@ strokes, effects or a brush comes in as its drawn look (a group named after it). document, whole and editable: point type, area type (a story in several frames as threaded type) and type on a path, with its fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing. Where the text document can't say, type that shows is the page's, in its text object's -place. A file whose editing data has symbols, +place. A text object on a layer that shows and doesn't print keeps the type the text document makes for it, on that +layer, even where the page doesn't draw it. A file whose editing data has symbols, pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain groups open ungrouped), with a warning saying why. Layers look different when they draw an object the page doesn't (or diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1eae3e6f3..280a2b6bd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `