diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 565ae0b7..53efb67a 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -125,7 +125,7 @@ impl IccProfile { /// Built-in RGB working spaces from `moxcms`'s standard primaries. pub fn builtin_rgb(name: &str) -> Option { let p = match name { - super::ADOBE_RGB => ColorProfile::new_adobe_rgb(), + super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(), super::DISPLAY_P3 => ColorProfile::new_display_p3(), super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(), super::SRGB => ColorProfile::new_srgb(), diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 9fc84d1b..b237a936 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -1,8 +1,8 @@ //! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents, //! gamut checks and soft-proof transforms. //! -//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998) -//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. +//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB +//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. //! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model //! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers); //! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles). @@ -25,7 +25,8 @@ pub use lab::{Lab, delta_e76, delta_e2000}; use crate::Color; pub const SRGB: &str = "sRGB IEC61966-2.1"; -pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible"; +/// The wide-gamut RGB space with the 1998 primaries (γ 2.2, D65). +pub const WIDE_GAMUT_RGB: &str = "Wide Gamut RGB (1998 primaries)"; pub const DISPLAY_P3: &str = "Display P3"; pub const PROPHOTO_RGB: &str = "ProPhoto RGB"; /// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile. @@ -33,6 +34,14 @@ pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)"; /// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers. pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)"; +/// Built-in RGB and CMYK working spaces, in menu order. +const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB]; +const BUILTIN_CMYK: [&str; 2] = [GENERIC_CMYK, DEVICE_CMYK]; + +/// Names earlier versions gave built-in profiles: (old name, current name). Settings, documents +/// and commands that still use an old name get the current profile. +const LEGACY_NAMES: &[(&str, &str)] = &[("Adobe RGB (1998) compatible", WIDE_GAMUT_RGB)]; // brand-ok: legacy alias + /// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning). pub const GAMUT_THRESHOLD: f32 = 2.0; @@ -169,7 +178,7 @@ fn user_profile(name: &str) -> Option> { } fn cmyk_space(name: &str) -> Result { - match name { + match canonical_name(name) { GENERIC_CMYK => Ok(CmykSpace::Generic(generic())), DEVICE_CMYK => Ok(CmykSpace::Device), _ => match user_profile(name) { @@ -194,12 +203,23 @@ fn rgb_space(name: &str) -> Result { } } +/// The current name of a profile: names earlier versions used resolve to today's name. +pub fn canonical_name(name: &str) -> &str { + LEGACY_NAMES.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new) +} + +/// An available profile by its current or legacy name. +pub fn profile(name: &str) -> Option { + let name = canonical_name(name); + profiles().into_iter().find(|p| p.name == name) +} + /// Every profile available (built-in first, then user-loaded). pub fn profiles() -> Vec { - let mut v: Vec = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB] + let mut v: Vec = BUILTIN_RGB .iter() .map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true }) - .chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) + .chain(BUILTIN_CMYK.iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) .collect(); for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() { v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false }); @@ -214,7 +234,8 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Result { - Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? }) + let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() }; + Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings }) } pub fn settings(&self) -> &ColorSettings { &self.settings diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 4e4f1d8b..57b7e78e 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -138,11 +138,11 @@ fn convert_between_models() { #[test] fn wide_gamut_working_space() { - let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap(); + let c = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); assert!(!c.rgb_is_srgb()); let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]); assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}"); - // Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green. + // Wide-gamut green is outside sRGB: it clips to (nearly) full sRGB green. let g = c.rgb_to_srgb([0.0, 1.0, 0.0]); assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}"); let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3])); @@ -150,6 +150,21 @@ fn wide_gamut_working_space() { assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_)))); } +#[test] +fn legacy_profile_name_resolves() { + const OLD: &str = "Adobe RGB (1998) compatible"; // brand-ok: legacy alias under test + assert_eq!(canonical_name(OLD), WIDE_GAMUT_RGB); + assert_eq!(canonical_name(SRGB), SRGB); + assert_eq!(profile(OLD).map(|p| (p.name, p.kind, p.builtin)), Some((WIDE_GAMUT_RGB.to_string(), ProfileKind::Rgb, true))); + assert!(profile("Nope").is_none()); + assert!(profiles().iter().all(|p| p.name != OLD), "only the current name is listed"); + // Settings saved with the old name load as the current profile, under its current name. + let c = Cms::new(&ColorSettings { rgb: OLD.into(), ..Default::default() }).unwrap(); + assert_eq!(c.settings().rgb, WIDE_GAMUT_RGB); + let same = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert_eq!(c.rgb_to_srgb([0.0, 1.0, 0.0]), same.rgb_to_srgb([0.0, 1.0, 0.0])); +} + #[test] fn proof_lut_matches_direct_proof() { let c = cms(); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index be83a99e..bf15a7f6 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -157,10 +157,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { let mut st = cms::active_settings(); let before = st.clone(); if let Some(v) = str_param(p, "rgb") { - st.rgb = v.to_string(); + st.rgb = cms::canonical_name(v).to_string(); } if let Some(v) = str_param(p, "cmyk") { - st.cmyk = v.to_string(); + st.cmyk = cms::canonical_name(v).to_string(); } if let Some(i) = intent_param(p, C)? { st.intent = i; @@ -189,24 +189,23 @@ fn load_profile(_: &mut Session, _: &Value) -> Result { /// The document's assigned profiles (`None` = working space). pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option, Option) { let o = d.unknown.get(PROFILES_KEY); - let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(str::to_string); + let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(|n| cms::canonical_name(n).to_string()); (get("rgb"), get("cmyk")) } fn assign_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.assignProfile"; let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); - let known = cms::profiles(); let pick = |key: &str, cur: &mut Option, kind: cms::ProfileKind| -> Result { match p.get(key) { None => Ok(false), Some(Value::Null) => Ok(cur.take().is_some()), Some(Value::String(n)) => { - if !known.iter().any(|k| &k.name == n && k.kind == kind) { + let Some(info) = cms::profile(n).filter(|k| k.kind == kind) else { return Err(bad(C, format!("unknown {key} profile `{n}`"))); - } - let changed = cur.as_deref() != Some(n.as_str()); - *cur = Some(n.clone()); + }; + let changed = cur.as_deref() != Some(info.name.as_str()); + *cur = Some(info.name); Ok(changed) } Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))), diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 02f25c27..78f793f2 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -377,3 +377,24 @@ fn intents_reach_the_document_conversion() { assert_ne!(first(&a), first(&b)); let _ = Intent::Perceptual; } + +#[test] +fn legacy_profile_name_resolves_in_commands_and_files() { + const OLD: &str = "Adobe RGB (1998) compatible"; // brand-ok: legacy alias under test + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let before = cms::active_settings(); + let r = s.execute("edit.colorSettings", &json!({"rgb": OLD})).unwrap(); + assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB); + assert_eq!(cms::active_settings().rgb, cms::WIDE_GAMUT_RGB); + let names: Vec<&str> = r["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert!(names.contains(&cms::WIDE_GAMUT_RGB) && !names.contains(&OLD)); + let r = s.execute("edit.assignProfile", &json!({"rgb": OLD})).unwrap(); + assert_eq!(r["rgb"], cms::WIDE_GAMUT_RGB, "stored under the current name"); + // A document saved by an older version names the profile the old way. + let mut d = (*s.doc().unwrap().doc).clone(); + d.unknown.insert(cmd::colormgmt::PROFILES_KEY.into(), json!({"rgb": OLD, "cmyk": null})); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert_eq!(cmd::colormgmt::doc_profiles(&back), (Some(cms::WIDE_GAMUT_RGB.to_string()), None)); + cms::set_active(&before).unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6495d65d..08a13ff2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,9 @@ pub mod unsaved; pub mod widgets; pub mod workspaces; +#[cfg(test)] +mod tests_labels; + use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e1fc19d7..8408069c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -20,7 +20,7 @@ pub enum Item { Todo(&'static str, &'static str), Sub(&'static str, Vec), Sep, - /// Section header (disabled label, e.g. "Illustrator Effects"). + /// Section header (disabled label, e.g. "Vector Effects"). Header(&'static str), } @@ -39,6 +39,10 @@ fn todos(label: &'static str, sc: &'static str) -> Item { fn sub(label: &'static str, items: Vec) -> Item { Item::Sub(label, items) } +/// Window → … Libraries until the code-generated libraries land (disabled entries). +fn library_placeholders() -> Vec { + vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")] +} use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). @@ -1218,10 +1222,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ), todo("Variables"), Sep, - sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]), - sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]), - sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]), - sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]), + sub("Brush Libraries", library_placeholders()), + sub("Graphic Style Libraries", library_placeholders()), + sub("Swatch Libraries", library_placeholders()), + sub("Symbol Libraries", library_placeholders()), ], ), ( @@ -1576,7 +1580,7 @@ fn font_items() -> Vec { .collect() } -/// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects. +/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. fn effect_menu() -> Vec { let cat = vectorcraft_effects::effect_catalog(); let mut out = vec![ @@ -1585,7 +1589,7 @@ fn effect_menu() -> Vec { Sep, c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), Sep, - Item::Header("Illustrator Effects"), + Item::Header("Vector Effects"), ]; // Submenus in Illustrator's order. let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index adc7973a..a789e066 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -157,9 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { widgets::dim_label(ui, "Stroke:"); for (icon, tip) in [ - ("dc-stroke-center", "Apply gradient within stroke"), - ("dc-stroke-outside", "Apply gradient along stroke (on the roadmap)"), - ("dc-stroke-inside", "Apply gradient across stroke (on the roadmap)"), + ("dc-stroke-center", "Gradient inside stroke"), + ("dc-stroke-outside", "Gradient along stroke (on the roadmap)"), + ("dc-stroke-inside", "Gradient across stroke (on the roadmap)"), ] { widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0); } diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index a0c932a5..90b456c5 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -14,8 +14,7 @@ pub const WEIGHT_PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; /// Width profile presets: (id for `stroke.set`, label). -pub const PROFILES: [(&str, &str); 4] = - [("uniform", "Uniform"), ("lens", "Width Profile 1"), ("taperStart", "Width Profile 4"), ("taperEnd", "Width Profile 5")]; +pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")]; /// Which preset a stroke's profile is (None profile = uniform). pub fn profile_id(p: Option<&WidthProfile>) -> &'static str { @@ -165,19 +164,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space((ui.available_width() - 56.0).max(0.0)); let on = dash.is_some(); - if widgets::icon_button_enabled(ui, "dc-dash-exact", "Preserves exact dash and gap lengths", on && !align_corners, on, 24.0).clicked() { + if widgets::icon_button_enabled(ui, "dc-dash-exact", "Exact dash lengths", on && !align_corners, on, 24.0).clicked() { set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false})); } - if widgets::icon_button_enabled( - ui, - "dc-dash-align", - "Aligns dashes to corners and path ends, adjusting lengths to fit", - on && align_corners, - on, - 24.0, - ) - .clicked() - { + if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() { set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true})); } }); @@ -242,8 +232,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.horizontal(|ui| { row_label(ui, "Align:"); - widgets::icon_button_enabled(ui, "dc-cap-square", "Extend arrow tip beyond end of path (on the roadmap)", true, false, 22.0); - widgets::icon_button_enabled(ui, "dc-cap-butt", "Place arrow tip at end of path (on the roadmap)", false, false, 22.0); + widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0); + widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0); }); widgets::divider(ui); // Profile. diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index ab9165de..2320f8f7 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -431,7 +431,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, "Spot Colors…", false, false); ui.separator(); menu_item(ui, "Open Swatch Library", false, false); - menu_item(ui, "Export Swatches Library as ASE…", false, false); + menu_item(ui, "Save Swatch Library…", false, false); } #[cfg(test)] diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs new file mode 100644 index 00000000..02ce2424 --- /dev/null +++ b/crates/ui-egui/src/tests_labels.rs @@ -0,0 +1,69 @@ +//! Neutral wording in menus and panels: what users read names VectorCraft's own features. + +use egui::epaint::Shape; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; +use crate::menus::{self, Item}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app +} + +/// Every string painted by `f` in one headless frame, one per line. +fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { + fn collect(s: &Shape, out: &mut String) { + match s { + Shape::Text(t) => { + out.push_str(t.galley.text()); + out.push('\n'); + } + Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + let mut text = String::new(); + for c in &out.shapes { + collect(&c.shape, &mut text); + } + text +} + +#[test] +fn effect_menu_has_a_vector_effects_section() { + let tree = menus::menu_tree(); + let (_, effect) = tree.iter().find(|(t, _)| *t == "Effect").expect("Effect menu"); + assert!(effect.iter().any(|i| matches!(i, Item::Header("Vector Effects")))); +} + +#[test] +fn library_submenus_are_disabled_placeholders() { + let app = app(); + let entries = menus::menu_entries(&app); + for lib in ["Brush Libraries", "Graphic Style Libraries", "Swatch Libraries", "Symbol Libraries"] { + let items: Vec<_> = entries.iter().filter(|e| e.path == ["Window", lib]).collect(); + let labels: Vec<&str> = items.iter().map(|e| e.label.as_str()).collect(); + assert_eq!(labels, ["Built-in Libraries", "User Defined", "Other Library…"], "{lib}"); + assert!(items.iter().all(|e| !e.enabled && e.command.is_none()), "{lib}"); + } +} + +#[test] +fn stroke_profiles_have_descriptive_names() { + let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect(); + assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]); +} + +#[test] +fn swatches_menu_offers_save_swatch_library() { + let mut app = app(); + let text = painted_text(&mut app, crate::panels::swatches::menu); + assert!(text.contains("Save Swatch Library…"), "{text}"); + assert!(!text.contains("ASE"), "{text}"); +}