M3.7: neutral paint, effect and stroke labels

The wide-gamut RGB working space is now "Wide Gamut RGB (1998 primaries)";
its old name still resolves everywhere (cms::profile / canonical_name,
Color Settings, Assign Profile, documents and settings saved earlier).
The Effect menu section is "Vector Effects", stroke width profiles are
Lens / Taper Start / Taper End, dash, arrowhead-tip and gradient-on-stroke
tooltips are reworded, the Window library submenus hold neutral disabled
placeholders until the generated libraries land, and the Swatches menu
offers "Save Swatch Library…" (disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 11:17:50 +07:00
parent d99853ed2a
commit 016fcbf34e
11 changed files with 169 additions and 45 deletions

View File

@ -125,7 +125,7 @@ impl IccProfile {
/// Built-in RGB working spaces from `moxcms`'s standard primaries. /// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> { pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
let p = match name { 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::DISPLAY_P3 => ColorProfile::new_display_p3(),
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(), super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
super::SRGB => ColorProfile::new_srgb(), super::SRGB => ColorProfile::new_srgb(),

View File

@ -1,8 +1,8 @@
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents, //! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
//! gamut checks and soft-proof transforms. //! gamut checks and soft-proof transforms.
//! //!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998) //! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB. //! (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 //! * **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); //! ([`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). //! 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; use crate::Color;
pub const SRGB: &str = "sRGB IEC61966-2.1"; 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 DISPLAY_P3: &str = "Display P3";
pub const PROPHOTO_RGB: &str = "ProPhoto RGB"; pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile. /// 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. /// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)"; 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). /// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
pub const GAMUT_THRESHOLD: f32 = 2.0; pub const GAMUT_THRESHOLD: f32 = 2.0;
@ -169,7 +178,7 @@ fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
} }
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> { fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
match name { match canonical_name(name) {
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())), GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
DEVICE_CMYK => Ok(CmykSpace::Device), DEVICE_CMYK => Ok(CmykSpace::Device),
_ => match user_profile(name) { _ => match user_profile(name) {
@ -194,12 +203,23 @@ fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
} }
} }
/// 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<ProfileInfo> {
let name = canonical_name(name);
profiles().into_iter().find(|p| p.name == name)
}
/// Every profile available (built-in first, then user-loaded). /// Every profile available (built-in first, then user-loaded).
pub fn profiles() -> Vec<ProfileInfo> { pub fn profiles() -> Vec<ProfileInfo> {
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB] let mut v: Vec<ProfileInfo> = BUILTIN_RGB
.iter() .iter()
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true }) .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(); .collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() { 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 }); v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
@ -214,7 +234,8 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
if p.kind == ProfileKind::Gray { if p.kind == ProfileKind::Gray {
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into())); return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
} }
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) { let builtin = canonical_name(&p.name);
if BUILTIN_RGB.contains(&builtin) || BUILTIN_CMYK.contains(&builtin) {
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name))); return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
} }
if !p.usable() { if !p.usable() {
@ -257,8 +278,10 @@ impl Default for Cms {
} }
impl Cms { impl Cms {
/// Profiles named by a legacy name are stored under their current name.
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> { pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
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 { pub fn settings(&self) -> &ColorSettings {
&self.settings &self.settings

View File

@ -138,11 +138,11 @@ fn convert_between_models() {
#[test] #[test]
fn wide_gamut_working_space() { 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()); assert!(!c.rgb_is_srgb());
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]); let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}"); 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]); let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}"); 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])); 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(_)))); 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] #[test]
fn proof_lut_matches_direct_proof() { fn proof_lut_matches_direct_proof() {
let c = cms(); let c = cms();

View File

@ -157,10 +157,10 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
let mut st = cms::active_settings(); let mut st = cms::active_settings();
let before = st.clone(); let before = st.clone();
if let Some(v) = str_param(p, "rgb") { 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") { 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)? { if let Some(i) = intent_param(p, C)? {
st.intent = i; st.intent = i;
@ -189,24 +189,23 @@ fn load_profile(_: &mut Session, _: &Value) -> Result<Value> {
/// The document's assigned profiles (`None` = working space). /// The document's assigned profiles (`None` = working space).
pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) { pub fn doc_profiles(d: &vectorcraft_doc::Document) -> (Option<String>, Option<String>) {
let o = d.unknown.get(PROFILES_KEY); 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")) (get("rgb"), get("cmyk"))
} }
fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> { fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.assignProfile"; const C: &str = "edit.assignProfile";
let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc);
let known = cms::profiles();
let pick = |key: &str, cur: &mut Option<String>, kind: cms::ProfileKind| -> Result<bool> { let pick = |key: &str, cur: &mut Option<String>, kind: cms::ProfileKind| -> Result<bool> {
match p.get(key) { match p.get(key) {
None => Ok(false), None => Ok(false),
Some(Value::Null) => Ok(cur.take().is_some()), Some(Value::Null) => Ok(cur.take().is_some()),
Some(Value::String(n)) => { 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}`"))); return Err(bad(C, format!("unknown {key} profile `{n}`")));
} };
let changed = cur.as_deref() != Some(n.as_str()); let changed = cur.as_deref() != Some(info.name.as_str());
*cur = Some(n.clone()); *cur = Some(info.name);
Ok(changed) Ok(changed)
} }
Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))), Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))),

View File

@ -377,3 +377,24 @@ fn intents_reach_the_document_conversion() {
assert_ne!(first(&a), first(&b)); assert_ne!(first(&a), first(&b));
let _ = Intent::Perceptual; 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();
}

View File

@ -31,6 +31,9 @@ pub mod unsaved;
pub mod widgets; pub mod widgets;
pub mod workspaces; pub mod workspaces;
#[cfg(test)]
mod tests_labels;
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};

View File

@ -20,7 +20,7 @@ pub enum Item {
Todo(&'static str, &'static str), Todo(&'static str, &'static str),
Sub(&'static str, Vec<Item>), Sub(&'static str, Vec<Item>),
Sep, Sep,
/// Section header (disabled label, e.g. "Illustrator Effects"). /// Section header (disabled label, e.g. "Vector Effects").
Header(&'static str), 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 { fn sub(label: &'static str, items: Vec<Item>) -> Item {
Item::Sub(label, items) Item::Sub(label, items)
} }
/// Window → … Libraries until the code-generated libraries land (disabled entries).
fn library_placeholders() -> Vec<Item> {
vec![todo("Built-in Libraries"), todo("User Defined"), Sep, todo("Other Library…")]
}
use Item::Sep; use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc). /// UI-level commands: (id, label, shortcut, params doc).
@ -1218,10 +1222,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
), ),
todo("Variables"), todo("Variables"),
Sep, Sep,
sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]), sub("Brush Libraries", library_placeholders()),
sub("Graphic Style Libraries", vec![todo("Additive"), todo("Artistic Effects"), todo("Buttons and Rollovers")]), sub("Graphic Style Libraries", library_placeholders()),
sub("Swatch Libraries", vec![todo("Art History"), todo("Celebration"), todo("Color Properties"), todo("Nature"), todo("Web")]), sub("Swatch Libraries", library_placeholders()),
sub("Symbol Libraries", vec![todo("Arrows"), todo("Charts"), todo("Web Buttons and Bars")]), sub("Symbol Libraries", library_placeholders()),
], ],
), ),
( (
@ -1576,7 +1580,7 @@ fn font_items() -> Vec<Item> {
.collect() .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<Item> { fn effect_menu() -> Vec<Item> {
let cat = vectorcraft_effects::effect_catalog(); let cat = vectorcraft_effects::effect_catalog();
let mut out = vec![ let mut out = vec![
@ -1585,7 +1589,7 @@ fn effect_menu() -> Vec<Item> {
Sep, Sep,
c("Document Raster Effects Settings…", "document.rasterEffectsSettings"), c("Document Raster Effects Settings…", "document.rasterEffectsSettings"),
Sep, Sep,
Item::Header("Illustrator Effects"), Item::Header("Vector Effects"),
]; ];
// Submenus in Illustrator's order. // Submenus in Illustrator's order.
let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"];

View File

@ -157,9 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.horizontal(|ui| { ui.horizontal(|ui| {
widgets::dim_label(ui, "Stroke:"); widgets::dim_label(ui, "Stroke:");
for (icon, tip) in [ for (icon, tip) in [
("dc-stroke-center", "Apply gradient within stroke"), ("dc-stroke-center", "Gradient inside stroke"),
("dc-stroke-outside", "Apply gradient along stroke (on the roadmap)"), ("dc-stroke-outside", "Gradient along stroke (on the roadmap)"),
("dc-stroke-inside", "Apply gradient across 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); widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
} }

View File

@ -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]; [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). /// Width profile presets: (id for `stroke.set`, label).
pub const PROFILES: [(&str, &str); 4] = pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")];
[("uniform", "Uniform"), ("lens", "Width Profile 1"), ("taperStart", "Width Profile 4"), ("taperEnd", "Width Profile 5")];
/// Which preset a stroke's profile is (None profile = uniform). /// Which preset a stroke's profile is (None profile = uniform).
pub fn profile_id(p: Option<&WidthProfile>) -> &'static str { 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)); ui.add_space((ui.available_width() - 56.0).max(0.0));
let on = dash.is_some(); 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})); set(app, json!({"dash": dash_pattern(&fields), "alignDashes": false}));
} }
if widgets::icon_button_enabled( if widgets::icon_button_enabled(ui, "dc-dash-align", "Fit dashes to corners and ends", on && align_corners, on, 24.0).clicked() {
ui,
"dc-dash-align",
"Aligns dashes to corners and path ends, adjusting lengths to fit",
on && align_corners,
on,
24.0,
)
.clicked()
{
set(app, json!({"dash": dash_pattern(&fields), "alignDashes": true})); 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| { ui.horizontal(|ui| {
row_label(ui, "Align:"); 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-square", "Tip extends past the end (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-butt", "Tip on the end point (on the roadmap)", false, false, 22.0);
}); });
widgets::divider(ui); widgets::divider(ui);
// Profile. // Profile.

View File

@ -431,7 +431,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
menu_item(ui, "Spot Colors…", false, false); menu_item(ui, "Spot Colors…", false, false);
ui.separator(); ui.separator();
menu_item(ui, "Open Swatch Library", false, false); 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)] #[cfg(test)]

View File

@ -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}");
}