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:
parent
d99853ed2a
commit
016fcbf34e
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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"))),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue