feat(ui): Photoshop's New Guide: units and colour (#3553)

* feat(engine): accept Photoshop units for guide positions

view.newGuide and view.moveGuide take the position as before (a number
of pixels) or as text with a unit, as Photoshop's New Guide dialog does:
"50%", "2.5 cm", "1in", "12pt", "3pc", "100px". Percent is of the
document's width for a vertical guide and its height for a horizontal
one; points and picas follow Units & Rulers › Point Size. Fractional
positions are kept. Text that isn't a length, NaN and positions beyond
1,000,000 px are refused without recording a history step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(engine): guides can have their own colour

Photoshop's New Guide sets a colour per guide; PhotoCraft guides only had
the Preferences guide colour. Guides now keep optional colours, keyed by
orientation and position so code that drops or reorders guide positions
(crop, artboards, guide layouts) can never move a colour onto another
guide: an unmatched guide just uses the preference colour.

view.newGuide takes an optional "color": a name from New Guide's list
(lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, lightGray,
black) or "#rrggbb". Without one the guide uses the preference colour, as
guides dragged from the rulers always have. Moving a guide carries its
colour; deleting and clearing drop it (Clear Guides, Clear Canvas Guides,
Clear Selected Artboard Guides and New Guide Layout's Clear Existing
Guides alike); undo restores it. Guides are drawn in their own colour
(while New Guide Layout previews, the rest show the colour it will set), and .pcraft files keep it (omitted when no guide has
one, so older readers and files are unaffected). PSD export keeps guide
positions only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): Photoshop's New Guide dialog: units and colour

View › Guides › New Guide… was the generic schema dialog, whose position
field was whole pixels only and had no colour. It now has its own
dialog, as in Photoshop:

- Position opens in the ruler unit ("0 cm") and takes any unit
  Photoshop's field takes ("50%", "2.5 cm", "12 pt"); a bare number is
  in the ruler unit. The last guide reopens in whatever the ruler unit
  is then ("5.08 cm").
- Color lists Custom…, Light Blue, Light Red, Green, Medium Blue,
  Yellow, Magenta, Cyan (the default), Light Gray and Black. Custom…
  shows a swatch that opens the Color Picker. The guide stores the
  chosen colour itself, so a later change of the preference guide
  colour leaves it alone.
- A position the engine refuses reopens the dialog with what was typed
  and the engine's message, as Photoshop keeps New Guide open.

Light Blue, Light Red, Medium Blue and Light Gray are new to the
catalogues; they were translated for all 16 languages from the existing
colour terms in each and would benefit from native review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(engine): one unit-suffix table for typed lengths

New Guide's position field (Unit::parse_length) and the Crop tool's size
fields (crop_size::unit_of) each kept the same table of unit suffixes.
Unit::from_suffix is now the one table: px, in or ", cm, mm, pt, pc or
pica, % and their long forms, in any case. The crop fields keep their own
number parsing (they accept arithmetic) and still refuse percent; they now
also accept "pc" for picas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): New Guide and New Guide Layout share one colour list

New Guide Layout (#2930) offered five colours of its own; New Guide offers
Photoshop's nine. Both dialogs now list the same nine (GUIDE_COLORS), in
Photoshop's order and with the same translated names. Cyan (#4affff, the
default guide colour), Magenta (#ff00ff) and Yellow (#ffcc00) take New
Guide Layout's values; Light Blue, Light Red, Green, Medium Blue, Light
Gray and Black keep New Guide's. New Guide Layout's Blue (#378ef0) and
jade Green (#00a86b) give way to Photoshop's Light Blue and Green; a colour
outside the list still shows as Custom.

GUIDE_COLORS drops its unused label column (the dialogs translate the
names), and New Guide Layout reads "#rrggbb" with prefs::parse_hex instead
of its own copy (rulers::parse_guide_color).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
FinnNkandClaude Opus 5.5 authored and GitHub committed 2026-10-12 00:46:56 -04:00
1 parent 973e7776ea
commit 962e0a2f98
35 files changed
+770 -50

No files matched your search

+36
View File
@@ -723,6 +723,42 @@ impl Layer {
pub struct Guides {
pub horizontal: Vec<f64>,
pub vertical: Vec<f64>,
/// Guides given their own colour (View › Guides › New Guide… › Color). The rest use
/// Preferences › Guides, Grid & Slices › Guide colour.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub colors: Vec<GuideColor>,
}
/// One guide's own colour. It is keyed by orientation and position, not by index, so code that
/// drops or reorders guide positions (crop, artboards, guide layouts) can never move a colour onto
/// another guide: a guide whose entry no longer matches simply uses the preference colour.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct GuideColor {
pub vertical: bool,
pub position: f64,
/// sRGB, 0–255.
pub rgb: [u8; 3],
}
impl Guides {
/// The guide's own colour, if it has one.
pub fn color(&self, vertical: bool, position: f64) -> Option<[u8; 3]> {
self.colors.iter().find(|c| c.vertical == vertical && c.position == position).map(|c| c.rgb)
}
/// Give the guide at `position` its own colour (`None` returns it to the preference colour).
pub fn set_color(&mut self, vertical: bool, position: f64, rgb: Option<[u8; 3]>) {
self.colors.retain(|c| !(c.vertical == vertical && c.position == position));
if let Some(rgb) = rgb {
self.colors.push(GuideColor { vertical, position, rgb });
}
}
/// Drop colours whose guide no longer exists (after a guide moved, was deleted or cleared).
pub fn prune_colors(&mut self) {
let (h, v) = (&self.horizontal, &self.vertical);
self.colors.retain(|c| if c.vertical { v.contains(&c.position) } else { h.contains(&c.position) });
}
}
/// Which areas an alpha channel's overlay colour marks (Channel Options › Color Indicates).
+1
View File
@@ -474,6 +474,7 @@ fn clear_artboard_guides(s: &mut Session) -> Result<Value> {
s.edit("Clear Selected Artboard Guides", |doc, _| {
doc.guides.vertical.retain(|x| !inside_x(x));
doc.guides.horizontal.retain(|y| !inside_y(y));
doc.guides.prune_colors();
Ok(())
})?;
Ok(json!({"cleared": n}))
+25
View File
@@ -108,6 +108,31 @@ fn clear_artboard_guides_only_inside() {
assert_eq!(doc(&s).guides.vertical, vec![10.0]);
}
/// Clearing guides drops their colours, so a guide added later at the same place doesn't inherit one.
#[test]
fn clearing_guides_drops_their_colours() {
let mut s = session(8);
let coloured = |s: &mut Session, x: f64| s.execute("view.newGuide", json!({"orientation": "vertical", "position": x, "color": "magenta"})).unwrap();
let colour = |s: &Session, x: f64| doc(s).guides.color(true, x);
s.execute("layer.new.artboard", json!({"rect": [0, 0, 50, 50]})).unwrap();
coloured(&mut s, 10.0);
coloured(&mut s, 70.0);
s.execute("view.clearSelectedArtboardGuides", json!({})).unwrap();
s.execute("view.clearCanvasGuides", json!({})).unwrap();
assert!(doc(&s).guides.colors.is_empty());
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 70})).unwrap();
assert_eq!((colour(&s, 10.0), colour(&s, 70.0)), (None, None));
// New Guide Layout's Clear Existing Guides likewise; a guide it keeps keeps its colour.
s.execute("view.clearGuides", json!({})).unwrap();
coloured(&mut s, 0.0);
s.execute("view.newGuideLayout", json!({"columns": 1})).unwrap();
assert_eq!(colour(&s, 0.0), Some([0xff, 0x00, 0xff]));
s.execute("view.newGuideLayout", json!({"columns": 1, "clearExisting": true})).unwrap();
assert!(doc(&s).guides.vertical.contains(&0.0));
assert_eq!(colour(&s, 0.0), None);
}
#[test]
fn export_to_files_and_pdf() {
let mut s = session(16);
+211 -12
View File
@@ -675,10 +675,65 @@ fn transform_again_params(s: &Session) -> Result<Value> {
Ok(p)
}
/// Guides further than this from the canvas origin are refused: far past any document (300000 px)
/// yet well within f32 precision.
const MAX_GUIDE_PX: f64 = 1_000_000.0;
/// A guide `position` in document pixels: a number of pixels, or text with a unit as in Photoshop's
/// New Guide dialog ("50%", "2.5 cm", "12 pt"). Percent is of the document's width for a vertical
/// guide and its height for a horizontal one; points and picas follow Units & Rulers › Point Size.
/// `None` when there is no `position`.
fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result<Option<f64>> {
let Some(v) = p.get("position") else { return Ok(None) };
let px = match (v.as_f64(), v.as_str()) {
(Some(n), _) => n,
(None, Some(text)) => {
let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| {
EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %"))
})?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let extent = if vertical { doc.size.width } else { doc.size.height };
unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch())
}
_ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())),
};
if !px.is_finite() || px.abs() > MAX_GUIDE_PX {
return Err(EngineError::Other(format!("`position` is out of range (at most {MAX_GUIDE_PX} px from the canvas origin)")));
}
Ok(Some(px))
}
/// Photoshop's guide colours, in its order, by the names `view.newGuide` takes: New Guide's and
/// New Guide Layout's Color lists. Cyan (the default guide colour in Preferences, so a guide
/// dragged from a ruler reads as Cyan), Magenta and Yellow are the values New Guide Layout first
/// shipped with; the rest approximate Photoshop's on screen, rounded to web-safe steps.
pub const GUIDE_COLORS: [(&str, [u8; 3]); 9] = [
("lightBlue", [0x33, 0x66, 0xff]),
("lightRed", [0xff, 0x33, 0x33]),
("green", [0x33, 0xff, 0x33]),
("mediumBlue", [0x33, 0x33, 0xff]),
("yellow", [0xff, 0xcc, 0x00]),
("magenta", [0xff, 0x00, 0xff]),
("cyan", [0x4a, 0xff, 0xff]),
("lightGray", [0xe7, 0xe7, 0xe7]),
("black", [0x00, 0x00, 0x00]),
];
/// A guide `color`: one of [`GUIDE_COLORS`]' names or `"#rrggbb"`. `None` when there is no `color`
/// (the guide uses Preferences › Guides, Grid & Slices › Guide colour).
fn guide_color(p: &Value) -> Result<Option<[u8; 3]>> {
let Some(v) = p.get("color") else { return Ok(None) };
let text = v.as_str().ok_or(EngineError::Other("`color` must be a colour name or \"#rrggbb\"".into()))?;
GUIDE_COLORS.iter().find(|(name, ..)| name.eq_ignore_ascii_case(text)).map(|(.., rgb)| *rgb).or_else(|| crate::prefs::parse_hex(text)).map(Some).ok_or_else(
|| EngineError::Other(format!("`color` {text:?} is not a guide colour: use \"#rrggbb\" or one of {}", GUIDE_COLORS.map(|c| c.0).join(", "))),
)
}
/// View › New Guide / guide moves (undoable, like Photoshop's "New Guide"/"Move Guide" states).
fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let vertical = p.get("orientation").and_then(Value::as_str).unwrap_or("horizontal") == "vertical";
let pos = p.get("position").and_then(Value::as_f64);
let pos = guide_position(s, p, vertical)?;
let color = if op == "new" { guide_color(p)? } else { None };
let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize);
let label = match op {
"new" => "New Guide",
@@ -687,20 +742,35 @@ fn guide_cmd(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
_ => "Clear Guides",
};
s.edit(label, |doc, _| {
let g = if vertical { &mut doc.guides.vertical } else { &mut doc.guides.horizontal };
let guides = &mut doc.guides;
let g = if vertical { &mut guides.vertical } else { &mut guides.horizontal };
match op {
"new" => g.push(pos.ok_or(EngineError::Other("missing `position`".into()))?),
"new" => {
let at = pos.ok_or(EngineError::Other("missing `position`".into()))?;
g.push(at);
if color.is_some() {
guides.set_color(vertical, at, color);
}
}
"move" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
g[i] = pos.ok_or(EngineError::Other("missing `position`".into()))?;
let to = pos.ok_or(EngineError::Other("missing `position`".into()))?;
let from = std::mem::replace(&mut g[i], to);
// The guide takes its colour along; another guide left at the old position keeps it.
if let Some(rgb) = guides.color(vertical, from) {
guides.set_color(vertical, to, Some(rgb));
}
guides.prune_colors();
}
"delete" => {
let i = index.filter(|i| *i < g.len()).ok_or(EngineError::Other("no such guide".into()))?;
g.remove(i);
guides.prune_colors();
}
_ => {
doc.guides.horizontal.clear();
doc.guides.vertical.clear();
guides.horizontal.clear();
guides.vertical.clear();
guides.colors.clear();
}
}
Ok(())
@@ -794,12 +864,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
|s, _| transform_again_copy(s)
),
spec!("view.newGuide", "New Guide…", &["View"], None, r##"{"orientation":"horizontal|vertical","position":px}"##, has_doc, |s, p| guide_cmd(
s, p, "new"
)),
spec!("view.moveGuide", "Move Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n,"position":px}"##, has_doc, |s, p| guide_cmd(
s, p, "move"
)),
spec!(
"view.newGuide",
"New Guide…",
&["View"],
None,
r##"{"orientation":"horizontal|vertical","position":px or "<number><unit>","color":"lightBlue|lightRed|green|mediumBlue|yellow|magenta|cyan|lightGray|black" or "#rrggbb"?} (units px, in, cm, mm, pt, pc or %; % is of the width for a vertical guide, the height for a horizontal one; without a color the guide uses the guide colour in Preferences)"##,
has_doc,
|s, p| guide_cmd(s, p, "new")
),
spec!(
"view.moveGuide",
"Move Guide",
&[],
None,
r##"{"orientation":"horizontal|vertical","index":n,"position":px or "<number><unit>"} (units as for view.newGuide)"##,
has_doc,
|s, p| guide_cmd(s, p, "move")
),
spec!("view.deleteGuide", "Delete Guide", &[], None, r##"{"orientation":"horizontal|vertical","index":n}"##, has_doc, |s, p| guide_cmd(s, p, "delete")),
spec!("view.clearGuides", "Clear Guides", &["View"], None, "{}", has_doc, |s, p| guide_cmd(s, p, "clear")),
]
@@ -1687,6 +1769,123 @@ mod tests {
assert!(s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 3, "position": 1})).is_err());
}
/// The last guide added in `orientation` for `position`.
fn new_guide(s: &mut Session, orientation: &str, position: Value) -> f64 {
s.execute("view.newGuide", json!({"orientation": orientation, "position": position})).unwrap();
let g = &s.active().unwrap().doc.guides;
*if orientation == "vertical" { &g.vertical } else { &g.horizontal }.last().unwrap()
}
#[test]
fn guide_positions_take_photoshop_units() {
let mut s = Session::new();
// 4 × 2 inches at 100 ppi.
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
// Percent follows the guide's own axis: the width for a vertical guide, the height for a horizontal one.
assert_eq!(new_guide(&mut s, "vertical", json!("50%")), 200.0);
assert_eq!(new_guide(&mut s, "horizontal", json!("50%")), 100.0);
for one_inch in ["1in", "1 in", "2.54cm", "25.4 mm", "72pt", "6pc", "100px", "100", "1,0 inch"] {
assert!((new_guide(&mut s, "vertical", json!(one_inch)) - 100.0).abs() < 1e-3, "{one_inch}");
}
// Fractional and negative positions are kept; a plain number is pixels, as before.
assert_eq!(new_guide(&mut s, "horizontal", json!("12.5px")), 12.5);
assert_eq!(new_guide(&mut s, "vertical", json!("-10px")), -10.0);
assert_eq!(new_guide(&mut s, "vertical", json!(40)), 40.0);
// Points and picas follow Units & Rulers › Point Size.
s.execute("prefs.set", json!({"path": "unitsAndRulers.pointSize", "value": "traditional"})).unwrap();
assert!((new_guide(&mut s, "vertical", json!("72.27pt")) - 100.0).abs() < 1e-3);
// A guide moved with a unit lands where a new one would.
s.execute("view.moveGuide", json!({"orientation": "horizontal", "index": 0, "position": "25%"})).unwrap();
assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0);
}
#[test]
fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
let color = |s: &Session, vertical: bool, p: f64| s.active().unwrap().doc.guides.color(vertical, p);
// Without a colour a guide uses the preference colour, as ruler-drag guides always did.
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
assert_eq!(color(&s, true, 10.0), None);
assert!(s.active().unwrap().doc.guides.colors.is_empty());
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20, "color": "magenta"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 20, "color": "#123456"})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 30, "color": "LightBlue"})).unwrap();
assert_eq!(color(&s, true, 20.0), Some([0xff, 0x00, 0xff]));
assert_eq!(color(&s, false, 20.0), Some([0x12, 0x34, 0x56]), "the same position across is a different guide");
assert_eq!(color(&s, true, 30.0), Some([0x33, 0x66, 0xff]), "names are case-insensitive");
// Moving takes the colour along.
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 1, "position": 25})).unwrap();
assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (None, Some([0xff, 0x00, 0xff])));
// Undo restores it where it was.
s.undo();
assert_eq!((color(&s, true, 20.0), color(&s, true, 25.0)), (Some([0xff, 0x00, 0xff]), None));
// Deleting drops it; clearing drops them all.
s.execute("view.deleteGuide", json!({"orientation": "vertical", "index": 2})).unwrap();
assert_eq!(color(&s, true, 30.0), None);
assert_eq!(s.active().unwrap().doc.guides.colors.len(), 2);
s.execute("view.clearGuides", json!({})).unwrap();
assert!(s.active().unwrap().doc.guides.colors.is_empty());
}
#[test]
fn moving_one_of_two_coloured_guides_at_one_position_leaves_the_other_its_colour() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
for _ in 0..2 {
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 50, "color": "green"})).unwrap();
}
s.execute("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 60})).unwrap();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.color(true, 50.0), g.color(true, 60.0)), (Some([0x33, 0xff, 0x33]), Some([0x33, 0xff, 0x33])));
}
#[test]
fn cyan_is_the_default_guide_colour_so_ruler_guides_read_as_cyan() {
let default = crate::prefs::Preferences::default().guides_grid_and_slices.guide_color;
let cyan = GUIDE_COLORS.iter().find(|(name, ..)| *name == "cyan").map(|(.., rgb)| *rgb);
assert_eq!(cyan, crate::prefs::parse_hex(&default));
}
#[test]
fn bad_guide_colours_are_refused_without_a_guide() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200})).unwrap();
let undo_before = s.active().unwrap().history.can_undo();
for bad in [json!("puce"), json!("#12345"), json!("#gggggg"), json!(""), json!(7), json!(null), json!(["cyan"])] {
assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10, "color": bad.clone()})).is_err(), "{bad}");
}
let g = &s.active().unwrap().doc.guides;
assert!(g.vertical.is_empty() && g.colors.is_empty());
assert_eq!(s.active().unwrap().history.can_undo(), undo_before);
}
#[test]
fn bad_guide_positions_are_refused_without_a_guide() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
let undo_before = s.active().unwrap().history.can_undo();
for bad in [
json!("abc"),
json!("50 furlongs"),
json!(""),
json!("%"),
json!("NaN"),
json!("inf"),
json!("1e400"),
json!("2000000px"),
json!(1e300),
json!(true),
json!([1]),
] {
assert!(s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad.clone()})).is_err(), "{bad}");
}
assert!(s.execute("view.newGuide", json!({"orientation": "vertical"})).is_err(), "no position");
let g = &s.active().unwrap().doc.guides;
assert!(g.vertical.is_empty() && g.horizontal.is_empty());
assert_eq!(s.active().unwrap().history.can_undo(), undo_before, "a refused guide records no history step");
}
/// Guides keep their exact place (#2918): 1 cm at 300 ppi through New Guide and .pcraft, and
/// a 1/32 px PSD position past 2^19 px, where f32 has no 1/32 steps left.
#[test]
+1
View File
@@ -1338,6 +1338,7 @@ fn clear_canvas_guides(s: &mut Session) -> Result<Value> {
s.edit("Clear Canvas Guides", |doc, _| {
doc.guides.vertical.retain(|x| in_x(*x));
doc.guides.horizontal.retain(|y| in_y(*y));
doc.guides.prune_colors();
Ok(())
})?;
Ok(json!({"cleared": n}))
+3
View File
@@ -162,6 +162,9 @@ pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
} else {
guides = Guides::default();
}
// Cleared guides take their colours along, so a new guide at the same place doesn't
// inherit one.
guides.prune_colors();
}
for x in &added.vertical {
push_unique(&mut guides.vertical, *x)?;
+31
View File
@@ -160,6 +160,37 @@ impl Unit {
Unit::Inches => 3,
}
}
/// A length as typed in Photoshop's numeric fields: a number with an optional unit suffix
/// ("50%", "2.5 cm", "12pt", "3pc", "1,5 in", "100"). Returns the number and its unit (`None`
/// when there is no suffix, so the caller picks the default), or `None` if the text is not a
/// finite number followed by a known unit.
pub fn parse_length(text: &str) -> Option<(f64, Option<Unit>)> {
let t = text.trim();
let end = t.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | ',' | '-' | '+'))).unwrap_or(t.len());
let (number, suffix) = t.split_at(end);
let v: f64 = number.trim().replace(',', ".").parse().ok()?;
if !v.is_finite() {
return None;
}
let suffix = suffix.trim();
let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) };
Some((v, unit))
}
/// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or
/// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every
/// length field reads.
pub fn from_suffix(suffix: &str) -> Option<Unit> {
Some(match suffix.to_ascii_lowercase().as_str() {
"px" | "pixel" | "pixels" => Unit::Pixels,
"in" | "inch" | "inches" | "\"" => Unit::Inches,
"cm" | "centimeter" | "centimeters" | "centimetre" | "centimetres" => Unit::Centimeters,
"mm" | "millimeter" | "millimeters" | "millimetre" | "millimetres" => Unit::Millimeters,
"pt" | "point" | "points" => Unit::Points,
"pc" | "pica" | "picas" => Unit::Picas,
"%" | "percent" => Unit::Percent,
_ => return None,
})
}
}
impl PointSize {
+2 -1
View File
@@ -228,7 +228,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
inks: vec![photocraft_doc::DuotoneInk::new("Black", [0.0; 3]), photocraft_doc::DuotoneInk::new("PANTONE 151 C", [1.0, 0.5, 0.0])],
psd_raw: Some(vec![1, 2, 3]),
});
d.guides = Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25] };
d.guides =
Guides { horizontal: vec![10.0, 20.5], vertical: vec![100.25], colors: vec![GuideColor { vertical: true, position: 100.25, rgb: [0x33, 0x66, 0xff] }] };
let mut sel = Surface::new(PixelFormat::GRAY8);
scribble(&mut sel, 14, false);
d.selection = Some(sel);
+3 -3
View File
@@ -14,12 +14,12 @@ fn guides_in_numbers_vertical_first_like_resource_1032() {
// One vertical guide at x=20 inside the rect, one horizontal at y=90 outside it.
// In 1032's order the vertical guide is index 0 and the horizontal is index 1,
// so the artboard rect (x 10..30, y 0..100) must select index 0.
let g = Guides { horizontal: vec![90.0], vertical: vec![20.0] };
let g = Guides { horizontal: vec![90.0], vertical: vec![20.0], ..Default::default() };
let r = Rect::new(10, 0, 30, 50); // contains x=20 (vertical), not y=90
assert_eq!(guides_in(&g, r), vec![0], "the vertical guide is 1032 index 0");
// Both inside: vertical (1032 index 0) before horizontal (1032 index 1).
let g2 = Guides { horizontal: vec![50.0], vertical: vec![50.0] };
let g2 = Guides { horizontal: vec![50.0], vertical: vec![50.0], ..Default::default() };
let r2 = Rect::new(0, 0, 100, 100);
assert_eq!(guides_in(&g2, r2), vec![0, 1], "vertical must be numbered before horizontal");
}
@@ -34,7 +34,7 @@ fn doc_with_board(rect: Rect) -> Document {
g.artboard = Some(Artboard { rect, background: ArtboardBackground::White, preset: String::new() });
}
d.layers = vec![g1];
d.guides = Guides { horizontal: vec![90.0], vertical: vec![20.0] };
d.guides = Guides { horizontal: vec![90.0], vertical: vec![20.0], ..Default::default() };
d
}
+4 -11
View File
@@ -63,17 +63,9 @@ pub fn dropdown_options() -> Vec<(String, &'static str)> {
v
}
/// The unit a suffix names (`in`, `cm`, `mm`, `px`, `pt`, `pica`, and their long forms).
/// The unit a suffix names ([`Unit::from_suffix`]); a crop size has no percent.
fn unit_of(suffix: &str) -> Option<Unit> {
Some(match suffix.to_ascii_lowercase().as_str() {
"px" | "pixel" | "pixels" => Unit::Pixels,
"in" | "inch" | "inches" | "\"" => Unit::Inches,
"cm" | "centimeter" | "centimeters" | "centimetre" | "centimetres" => Unit::Centimeters,
"mm" | "millimeter" | "millimeters" | "millimetre" | "millimetres" => Unit::Millimeters,
"pt" | "point" | "points" => Unit::Points,
"pica" | "picas" => Unit::Picas,
_ => return None,
})
Unit::from_suffix(suffix).filter(|u| *u != Unit::Percent)
}
/// A typed length: a positive number and an optional unit ("4 in", "4in", "10,5 cm", "1024").
@@ -375,7 +367,8 @@ mod tests {
assert_eq!(parse_length("1024 px", Unit::Inches), Some((1024.0, Unit::Pixels)));
assert_eq!(parse_length("8,5 in", Unit::Pixels), Some((8.5, Unit::Inches)));
assert_eq!(parse_length("12", Unit::Centimeters), Some((12.0, Unit::Centimeters)), "a bare number keeps the field's unit");
for bad in ["", "in", "0 in", "-4 in", "4 furlongs", "NaN", "1e400 px", "4 in 5"] {
assert_eq!(parse_length("3 pc", Unit::Pixels), Some((3.0, Unit::Picas)), "the shared unit table");
for bad in ["", "in", "0 in", "-4 in", "4 furlongs", "NaN", "1e400 px", "4 in 5", "50 %", "50 percent"] {
assert_eq!(parse_length(bad, Unit::Pixels), None, "{bad}");
}
assert_eq!(normalize_length("4in", ""), "4 in");
+2
View File
@@ -267,6 +267,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
first_field_focused(ui, id, |ui| body(ui, &mut fields));
}
}
DialogKind::Command if crate::new_guide_ui::owns(&fields) => crate::new_guide_ui::body(ui, &mut fields),
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
DialogKind::Command => {}
DialogKind::LayerStyle => crate::layer_style::body(app, ui, &mut fields),
@@ -490,6 +491,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
DialogKind::Command if crate::crop_presets::owns(&d.fields) => crate::crop_presets::confirm(app, &d.fields),
DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields),
DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields),
DialogKind::Command if crate::new_guide_ui::owns(&d.fields) => crate::new_guide_ui::confirm(app, &d.fields),
DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields),
DialogKind::Command if crate::color_range_ui::owns(&d.fields) => crate::color_range_ui::confirm(app, &d.fields),
DialogKind::Command if crate::prefs_ui::owns(&d.fields) => crate::prefs_ui::confirm(app, &d.fields),
-1
View File
@@ -281,7 +281,6 @@ pub fn has_dialog(command: &str) -> bool {
|| matches!(
command,
"image.trim"
| "view.newGuide"
| "select.refineEdge"
| "edit.assignProfile"
| "edit.convertToProfile"
+23 -9
View File
@@ -8,7 +8,24 @@ use crate::{PhotocraftApp, theme::Tokens, widgets};
const COMMAND: &str = "view.newGuideLayout";
const MARK: &str = "__guideLayout";
const COLORS: [(&str, &str); 5] = [("#4affff", "Cyan"), ("#ff00ff", "Magenta"), ("#378ef0", "Blue"), ("#00a86b", "Green"), ("#ffcc00", "Yellow")];
/// A `"#rrggbb"` colour as drawn.
fn rgb(hex: &str) -> Option<Color32> {
photocraft_engine::prefs::parse_hex(hex).map(|[r, g, b]| Color32::from_rgb(r, g, b))
}
/// The Color list: New Guide's colours (`GUIDE_COLORS`) by their `"#rrggbb"`, then `current` as
/// Custom when it's none of them.
fn color_options(current: &str) -> Vec<(String, &'static str)> {
let mut colors: Vec<(String, &'static str)> = photocraft_engine::edit_cmds::GUIDE_COLORS
.iter()
.map(|(name, [r, g, b])| (format!("#{r:02x}{g:02x}{b:02x}"), crate::new_guide_ui::color_label(name)))
.collect();
if !colors.iter().any(|(v, _)| v == current) {
colors.push((current.to_string(), "Custom"));
}
colors
}
pub fn owns(f: &Map<String, Value>) -> bool {
f.contains_key(MARK)
@@ -102,7 +119,7 @@ fn calculate(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<Guides, Stri
pub fn validate(app: &PhotocraftApp, f: &Map<String, Value>) -> Result<(), String> {
let color = f.get("color").and_then(Value::as_str).unwrap_or("#4affff");
crate::rulers::parse_guide_color(color).ok_or("invalid guide color")?;
rgb(color).ok_or("invalid guide color")?;
calculate(app, f).map(|_| ())
}
@@ -110,7 +127,7 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
validate(app, f)?;
let color = f.get("color").and_then(Value::as_str).unwrap_or("#4affff");
// Colour is the existing global guide display preference, not document data.
let color = crate::rulers::parse_guide_color(color).ok_or("invalid guide color")?;
let color = rgb(color).ok_or("invalid guide color")?;
let hex = format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b());
let result = app.run(COMMAND, params(f))?;
app.session.prefs.edit(|prefs| prefs.guides_grid_and_slices.guide_color = hex);
@@ -130,7 +147,7 @@ pub fn preview(app: &PhotocraftApp, doc: &Document) -> Option<(Guides, Color32)>
return None;
}
let guides = calculate(app, f).ok().or_else(|| f.get("__previewGuides").and_then(|v| serde_json::from_value(v.clone()).ok()))?;
let color = f.get("color").and_then(Value::as_str).and_then(crate::rulers::parse_guide_color).unwrap_or(Color32::from_rgb(74, 255, 255));
let color = f.get("color").and_then(Value::as_str).and_then(rgb).unwrap_or(Color32::from_rgb(74, 255, 255));
Some((guides, color))
}
@@ -261,14 +278,11 @@ fn main_fields(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value
ui.horizontal(|ui| {
ui.label(tl!("Color:"));
let mut color = f.get("color").and_then(Value::as_str).unwrap_or("#4affff").to_string();
let mut colors: Vec<(String, &str)> = COLORS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
if !COLORS.iter().any(|(v, _)| *v == color) {
colors.push((color.clone(), "Custom"));
}
let colors = color_options(&color);
if widgets::dropdown(ui, "guide-color", &mut color, &colors, 174.0) {
f.insert("color".into(), json!(color));
}
let shown = crate::rulers::parse_guide_color(&color).unwrap_or(Tokens::get(ui.ctx()).accent);
let shown = rgb(&color).unwrap_or(Tokens::get(ui.ctx()).accent);
if widgets::color_swatch_button(ui, shown, tl!("Guide color")).clicked() {
crate::color_picker_ui::request(f, tl!("Guide color"));
}
@@ -53,6 +53,16 @@ fn tab_skips_disabled_rows_and_margins() {
assert_eq!(f["gutter"], 4.0);
}
/// New Guide Layout offers New Guide's colours, by value, and keeps another one as Custom.
#[test]
fn color_list_is_new_guides() {
let hexes: Vec<String> = color_options("#4affff").into_iter().map(|(hex, _)| hex).collect();
assert_eq!(hexes, ["#3366ff", "#ff3333", "#33ff33", "#3333ff", "#ffcc00", "#ff00ff", "#4affff", "#e7e7e7", "#000000"]);
assert_eq!(color_options("#4affff")[6].1, "Cyan", "the default guide colour reads as Cyan");
let custom = color_options("#123456");
assert_eq!((custom.len(), custom[9].0.as_str(), custom[9].1), (10, "#123456", "Custom"));
}
fn harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut initialized = false;
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
+4
View File
@@ -3657,3 +3657,7 @@ navigator Zoom Zvětšení
Apply mask to layer before removing? Použít masku na vrstvu před odstraněním?
The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu.
Delete the vector mask? Odstranit vektorovou masku?
# View › Guides › New Guide… › Color
Light Blue Světle modrá
Light Red Světle červená
Medium Blue Středně modrá
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2682,3 +2682,8 @@ curves Smooth Glätten
@plural {n} step|{n} steps {n} Schritt|{n} Schritte
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Pinselvorgaben-Warnung)|{warning} (+{n} weitere Pinselvorgaben-Warnungen)
@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen
# View › Guides › New Guide… › Color
Light Blue Hellblau
Light Red Hellrot
Medium Blue Mittelblau
Light Gray Hellgrau
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3740,3 +3740,8 @@ curves Smooth Εξομάλυνση
@plural {n} step|{n} steps {n} βήμα|{n} βήματα
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογών πινέλων)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων)
@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις
# View › Guides › New Guide… › Color
Light Blue Ανοιχτό μπλε
Light Red Ανοιχτό κόκκινο
Medium Blue Μεσαίο μπλε
Light Gray Ανοιχτό γκρι
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+5
View File
@@ -2723,3 +2723,8 @@ curves Smooth Suavizar
@plural {n} step|{n} steps {n} paso|{n} pasos
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias
# View › Guides › New Guide… › Color
Light Blue Azul claro
Light Red Rojo claro
Medium Blue Azul medio
Light Gray Gris claro
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -3043,3 +3043,8 @@ curves Smooth Lisser
Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ?
The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille.
Delete the vector mask? Supprimer le masque vectoriel ?
# View › Guides › New Guide… › Color
Light Blue Bleu clair
Light Red Rouge clair
Medium Blue Bleu moyen
Light Gray Gris clair
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3030,3 +3030,8 @@ curves Smooth Haluskan
Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus?
The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi.
Delete the vector mask? Hapus masker vektor?
# View › Guides › New Guide… › Color
Light Blue Biru Muda
Light Red Merah Terang
Medium Blue Biru Sedang
Light Gray Abu-abu Muda
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -2714,3 +2714,8 @@ curves Smooth Leviga
@plural {n} step|{n} steps {n} passaggio|{n} passaggi
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi
# View › Guides › New Guide… › Color
Light Blue Blu chiaro
Light Red Rosso chiaro
Medium Blue Blu medio
Light Gray Grigio chiaro
Can't render this file because it contains an unexpected character in line 2590 and column 53.
+5
View File
@@ -3652,3 +3652,8 @@ curves Smooth スムーズ
@plural {n} step|{n} steps {n} ステップ
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(ほかにブラシプリセットの警告 {n} 件)
@plural {what} with {n} warning|{what} with {n} warnings {what}(警告 {n} 件)
# View › Guides › New Guide… › Color
Light Blue ライトブルー
Light Red ライトレッド
Medium Blue ミディアムブルー
Light Gray ライトグレー
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3212,3 +3212,8 @@ curves Smooth 매끄럽게
Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까?
The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요.
Delete the vector mask? 벡터 마스크를 삭제하시겠습니까?
# View › Guides › New Guide… › Color
Light Blue 연한 파랑
Light Red 연한 빨강
Medium Blue 중간 파랑
Light Gray 연한 회색
Can't render this file because it contains an unexpected character in line 3099 and column 53.
+5
View File
@@ -3802,3 +3802,8 @@ curves Smooth Vloeiend maken
@plural {n} step|{n} steps {n} stap|{n} stappen
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} andere waarschuwing over penseelvoorinstellingen)|{warning} (+{n} andere waarschuwingen over penseelvoorinstellingen)
@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen
# View › Guides › New Guide… › Color
Light Blue Lichtblauw
Light Red Lichtrood
Medium Blue Middelblauw
Light Gray Lichtgrijs
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -3639,3 +3639,8 @@ curves Smooth Wygładź
Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem?
The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza.
Delete the vector mask? Usunąć maskę wektorową?
# View › Guides › New Guide… › Color
Light Blue Jasnoniebieski
Light Red Jasnoczerwony
Medium Blue Średnioniebieski
Light Gray Jasnoszary
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3832,3 +3832,8 @@ layerLabel No Color Sem cor
@plural {n} step|{n} steps {n} etapa|{n} etapas
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} outro aviso de predefinição de pincel)|{warning} (+{n} outros avisos de predefinição de pincel)
@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos
# View › Guides › New Guide… › Color
Light Blue Azul-claro
Light Red Vermelho-claro
Medium Blue Azul médio
Light Gray Cinza-claro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -2987,3 +2987,8 @@ curves Smooth Сгладить
@plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (и ещё {n} предупреждение о наборах кистей)|{warning} (и ещё {n} предупреждения о наборах кистей)|{warning} (и ещё {n} предупреждений о наборах кистей)
@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} предупреждение|{what}: {n} предупреждения|{what}: {n} предупреждений
# View › Guides › New Guide… › Color
Light Blue Светло-синий
Light Red Светло-красный
Medium Blue Средне-синий
Light Gray Светло-серый
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3740,3 +3740,8 @@ curves Smooth Згладити
@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попереджень щодо наборів пензлів)
@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} попередження|{what}: {n} попередження|{what}: {n} попереджень
# View › Guides › New Guide… › Color
Light Blue Світло-синій
Light Red Світло-червоний
Medium Blue Середньо-синій
Light Gray Світло-сірий
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+5
View File
@@ -2647,3 +2647,8 @@ curves Smooth 平滑
Apply mask to layer before removing? 移去之前将蒙版应用到图层吗?
The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。
Delete the vector mask? 要删除矢量蒙版吗?
# View › Guides › New Guide… › Color
Light Blue 浅蓝色
Light Red 浅红色
Medium Blue 中蓝色
Light Gray 浅灰色
Can't render this file because it contains an unexpected character in line 2532 and column 53.
+5
View File
@@ -2632,3 +2632,8 @@ curves Smooth 平滑
@plural {n} step|{n} steps {n} 個步驟
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告)
@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} 個警告)
# View › Guides › New Guide… › Color
Light Blue 淺藍色
Light Red 淺紅色
Medium Blue 中藍色
Light Gray 淺灰色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -124,6 +124,7 @@ pub mod move_mods;
pub mod move_ui;
pub mod native_menu;
pub mod new_doc_ui;
pub mod new_guide_ui;
pub mod new_layer_ui;
pub mod notices;
mod numeric_expression;
+1
View File
@@ -569,6 +569,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
sz if crate::sizing::is_sizing(sz) && params.as_object().is_none_or(|o| o.is_empty()) => {
Ok(json!({"dialog": crate::sizing::open(app, sz).ok_or("no document")?}))
}
"view.newGuide" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::new_guide_ui::open(app)})),
fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|o| o.is_empty()) => {
Ok(json!({"dialog": crate::filter_dialog::open(app, fl)}))
}
+186
View File
@@ -0,0 +1,186 @@
//! View › Guides › New Guide…: an orientation, a position with a unit and a colour, as in
//! Photoshop.
//!
//! The position opens in the ruler unit ("0 cm") and takes any unit Photoshop's field takes:
//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". A bare number is in the ruler unit, as in
//! Photoshop. OK runs `view.newGuide` with the text, so the engine converts it with the document's
//! resolution. A position the engine refuses reopens the dialog with what was typed and the
//! engine's message, as Photoshop keeps New Guide open on a bad position. The
//! dialog is an ordinary one in the UI state, so `ui.inspect`, `ui.dialog.confirm` and
//! `ui.dialog.cancel` drive it. The last successful guide's orientation and colour open next
//! time, with its position shown in the current ruler unit, as Photoshop does.
//!
//! Color lists Photoshop's guide colours (Cyan by default) and Custom…, whose swatch opens the
//! Color Picker. The guide stores the chosen colour itself, so changing the preference guide
//! colour later doesn't recolour it.
use photocraft_engine::edit_cmds::GUIDE_COLORS;
use photocraft_engine::prefs::Unit;
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::state::DialogKind;
const MARK: &str = "__newGuide";
const COMMAND: &str = "view.newGuide";
const CUSTOM: &str = "custom";
/// New Guide's colour when nothing was remembered, as in Photoshop.
const DEFAULT_COLOR: &str = "cyan";
/// A colour's label in the list (the catalogue holds each one).
pub(crate) fn color_label(name: &str) -> &'static str {
match name {
"lightBlue" => tl!("Light Blue"),
"lightRed" => tl!("Light Red"),
"green" => tl!("Green"),
"mediumBlue" => tl!("Medium Blue"),
"yellow" => tl!("Yellow"),
"magenta" => tl!("Magenta"),
"cyan" => tl!("Cyan"),
"lightGray" => tl!("Light Gray"),
"black" => tl!("Black"),
_ => tl!("Custom…"),
}
}
fn is_color_name(name: &str) -> bool {
name == CUSTOM || GUIDE_COLORS.iter().any(|(n, ..)| *n == name)
}
/// The Custom… colour as "#rrggbb", starting from Cyan's.
fn custom_color(f: &Map<String, Value>) -> String {
f.get("customColor")
.and_then(Value::as_str)
.filter(|h| photocraft_engine::prefs::parse_hex(h).is_some())
.map_or_else(|| "#4affff".to_string(), str::to_string)
}
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
fn ruler_unit(app: &PhotocraftApp) -> Unit {
app.session.prefs().units_and_rulers.rulers
}
/// "0 cm", "0 px", "0 %": a value in `unit` as the field shows it, with a space before the unit
/// as in Photoshop.
fn with_unit(value: &str, unit: Unit) -> String {
format!("{value} {}", unit.suffix())
}
/// `px` (document pixels) in the ruler unit, as the field shows it: "14.11 cm". Percent is of the
/// active document's width for a vertical guide and its height for a horizontal one.
fn in_ruler_unit(app: &PhotocraftApp, px: f64, vertical: bool) -> String {
let units = &app.session.prefs().units_and_rulers;
let (dpi, extent) = app.session.active().map_or((72.0, 1.0), |st| {
let d = &st.doc;
(f64::from(d.resolution_dpi), f64::from(if vertical { d.size.width } else { d.size.height }))
});
with_unit(&units.format(px, dpi, extent), units.rulers)
}
/// Open the dialog with the last successful guide's orientation, position (in the current ruler
/// unit) and colour, else horizontal at 0.
pub fn open(app: &mut PhotocraftApp) -> u64 {
let saved = app.session.prefs().dialogs.get(COMMAND).and_then(Value::as_object).cloned().unwrap_or_default();
let orientation = saved.get("orientation").and_then(Value::as_str).filter(|o| matches!(*o, "horizontal" | "vertical")).unwrap_or("horizontal");
let px = saved.get("positionPx").and_then(Value::as_f64).filter(|p| p.is_finite()).unwrap_or(0.0);
let position = in_ruler_unit(app, px, orientation == "vertical");
let color = saved.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string();
let custom = custom_color(&saved);
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
f.insert("__label".into(), json!("New Guide…"));
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(position));
f.insert("color".into(), json!(color));
f.insert("customColor".into(), json!(custom));
app.ui.open_dialog(DialogKind::Command, f)
}
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string();
let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string();
let mut color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR).to_string();
if let Some(error) = f.get("__error").and_then(Value::as_str) {
ui.colored_label(ui.visuals().error_fg_color, error);
ui.add_space(4.0);
}
egui::Grid::new("new-guide-dialog").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Orientation"));
ui.horizontal(|ui| {
ui.radio_value(&mut orientation, "horizontal".to_string(), tl!("Horizontal"));
ui.radio_value(&mut orientation, "vertical".to_string(), tl!("Vertical"));
});
ui.end_row();
ui.label(tl!("Position"));
ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0));
ui.end_row();
ui.label(tl!("Color"));
ui.horizontal(|ui| {
// Custom… first, then Photoshop's colours.
let options: Vec<(String, &str)> =
std::iter::once(CUSTOM).chain(GUIDE_COLORS.iter().map(|(n, ..)| *n)).map(|n| (n.to_string(), color_label(n))).collect();
crate::widgets::dropdown(ui, "new-guide-color", &mut color, &options, 140.0);
if color == CUSTOM {
let hex = custom_color(f);
let rgb = photocraft_engine::prefs::parse_hex(&hex).unwrap_or([0x4a, 0xff, 0xff]);
if crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(rgb[0], rgb[1], rgb[2]), tl!("Custom…")).clicked() {
f.insert("customColor".into(), json!(hex));
crate::color_picker_ui::request_field(f, "customColor", &crate::color_picker_ui::title_for(tl!("Guides")));
}
ui.label(egui::RichText::new(hex).monospace());
}
});
ui.end_row();
});
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(position));
f.insert("color".into(), json!(color));
}
/// The command's parameters: a bare number gets the ruler unit; anything else goes as typed, for
/// the engine to read (or refuse, keeping the dialog open). Custom… sends its "#rrggbb".
pub fn params(app: &PhotocraftApp, f: &Map<String, Value>) -> Value {
let orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal");
let typed = f.get("position").and_then(Value::as_str).unwrap_or_default().trim();
let position = match Unit::parse_length(typed) {
Some((_, None)) => with_unit(typed, ruler_unit(app)),
_ => typed.to_string(),
};
let color = f.get("color").and_then(Value::as_str).filter(|c| is_color_name(c)).unwrap_or(DEFAULT_COLOR);
let color = if color == CUSTOM { custom_color(f) } else { color.to_string() };
json!({"orientation": orientation, "position": position, "color": color})
}
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let params = params(app, f);
match app.run(COMMAND, params.clone()) {
Ok(result) => {
// Remember where the guide landed (so it reopens in whatever the ruler unit is then),
// the list's choice (not the colour it sent) and the last Custom… colour.
let vertical = params["orientation"] == "vertical";
let landed = app.session.active().and_then(|st| if vertical { st.doc.guides.vertical.last() } else { st.doc.guides.horizontal.last() }.copied());
let remembered = json!({
"orientation": params["orientation"],
"positionPx": landed.unwrap_or(0.0),
"color": f.get("color").cloned().unwrap_or(json!(DEFAULT_COLOR)),
"customColor": custom_color(f),
});
app.session.prefs.edit(|prefs| prefs.dialogs.insert(COMMAND.into(), remembered));
Ok(result)
}
Err(e) => {
// `dialogs::confirm` has already closed it: open it again with the typed values.
let mut again = f.clone();
again.insert("__error".into(), json!(e));
app.ui.open_dialog(DialogKind::Command, again);
Err(e)
}
}
}
#[cfg(test)]
mod tests;
+144
View File
@@ -0,0 +1,144 @@
use serde_json::json;
use super::*;
use crate::menus::invoke as menu;
/// One 4 × 2 inch document at 100 ppi, with the rulers in `unit`.
fn app(unit: &str) -> (PhotocraftApp, egui::Context) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap();
app.sync_views();
(app, egui::Context::default())
}
fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> u64 {
menu(app, ctx, "view.newGuide", json!({})).unwrap()["dialog"].as_u64().unwrap()
}
fn set(app: &mut PhotocraftApp, d: u64, orientation: &str, position: &str) {
let f = &mut app.ui.dialog_mut(d).unwrap().fields;
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(position));
}
fn guides(app: &PhotocraftApp) -> (Vec<f64>, Vec<f64>) {
let g = &app.session.active().unwrap().doc.guides;
(g.horizontal.clone(), g.vertical.clone())
}
#[test]
fn opens_in_the_ruler_unit_and_takes_photoshop_units() {
let (mut app, ctx) = app("cm");
let d = open(&mut app, &ctx);
let f = &app.ui.dialog_mut(d).unwrap().fields;
assert!(owns(f), "New Guide has its own dialog, not the generic one");
assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 cm")));
// Percent is of the width for a vertical guide.
set(&mut app, d, "vertical", "50%");
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app), (vec![], vec![200.0]));
// The last guide opens next time, in the ruler unit (200 px at 100 ppi is 5.08 cm); a bare
// number is in the ruler unit, as in Photoshop.
let d = open(&mut app, &ctx);
let f = &app.ui.dialog_mut(d).unwrap().fields;
assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("vertical"), Some("5.08 cm")));
set(&mut app, d, "horizontal", "2.54");
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app), (vec![100.0], vec![200.0]));
// Any unit overrides the ruler unit.
let d = open(&mut app, &ctx);
set(&mut app, d, "horizontal", "36 pt");
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app).0, vec![100.0, 50.0]);
}
#[test]
fn percent_rulers_open_at_zero_percent() {
let (mut app, ctx) = app("percent");
let d = open(&mut app, &ctx);
assert_eq!(app.ui.dialog_mut(d).unwrap().fields["position"], "0 %");
set(&mut app, d, "horizontal", "25");
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app).0, vec![50.0], "25% of the 200 px height");
}
#[test]
fn a_refused_position_reopens_the_dialog_without_a_guide() {
let (mut app, ctx) = app("pixels");
let d = open(&mut app, &ctx);
set(&mut app, d, "vertical", "12 furlongs");
let err = crate::dialogs::confirm(&mut app, d).unwrap_err();
assert!(err.contains("not a length"), "{err}");
assert_eq!(guides(&app), (vec![], vec![]));
let again = app.ui.dialogs.iter().find(|d| owns(&d.fields)).expect("the dialog is open again");
assert_eq!(again.fields["position"], "12 furlongs", "what was typed is kept");
assert_eq!(again.fields["orientation"], "vertical");
assert_eq!(again.fields["__error"].as_str(), Some(err.as_str()));
// A refused position is not remembered.
assert!(!app.session.prefs().dialogs.contains_key("view.newGuide"));
}
#[test]
fn remembered_values_that_no_longer_parse_are_ignored() {
let (mut app, ctx) = app("mm");
app.session.prefs.edit(|p| p.dialogs.insert("view.newGuide".into(), json!({"orientation": "diagonal", "positionPx": "far away"})));
let d = open(&mut app, &ctx);
let f = &app.ui.dialog_mut(d).unwrap().fields;
assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 mm")));
}
fn color_of(app: &PhotocraftApp, vertical: bool, position: f64) -> Option<[u8; 3]> {
app.session.active().unwrap().doc.guides.color(vertical, position)
}
#[test]
fn guides_store_the_chosen_colour_cyan_by_default() {
let (mut app, ctx) = app("pixels");
let d = open(&mut app, &ctx);
assert_eq!(app.ui.dialog_mut(d).unwrap().fields["color"], "cyan");
set(&mut app, d, "vertical", "10");
crate::dialogs::confirm(&mut app, d).unwrap();
// Stored on the guide itself, so a later change of the preference colour leaves it alone.
assert_eq!(color_of(&app, true, 10.0), Some([0x4a, 0xff, 0xff]));
let d = open(&mut app, &ctx);
set(&mut app, d, "vertical", "20");
app.ui.dialog_mut(d).unwrap().fields.insert("color".into(), json!("magenta"));
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(color_of(&app, true, 20.0), Some([0xff, 0x00, 0xff]));
// Custom… sends the picked colour; the list's choice and the custom colour open next time.
let d = open(&mut app, &ctx);
assert_eq!(app.ui.dialog_mut(d).unwrap().fields["color"], "magenta");
set(&mut app, d, "horizontal", "30");
let f = &mut app.ui.dialog_mut(d).unwrap().fields;
f.insert("color".into(), json!("custom"));
f.insert("customColor".into(), json!("#abcdef"));
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(color_of(&app, false, 30.0), Some([0xab, 0xcd, 0xef]));
let d = open(&mut app, &ctx);
let f = &app.ui.dialog_mut(d).unwrap().fields;
assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#abcdef")));
}
#[test]
fn remembered_colours_that_are_not_on_the_list_are_ignored() {
let (mut app, ctx) = app("pixels");
app.session.prefs.edit(|p| p.dialogs.insert("view.newGuide".into(), json!({"color": "puce", "customColor": "#zzzzzz"})));
let d = open(&mut app, &ctx);
let f = &app.ui.dialog_mut(d).unwrap().fields;
assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("cyan"), Some("#4affff")));
}
#[test]
fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() {
let (mut app, ctx) = app("pixels");
let d = open(&mut app, &ctx);
set(&mut app, d, "horizontal", "1in");
crate::dialogs::confirm(&mut app, d).unwrap();
for (unit, shown) in [("pixels", "100 px"), ("inches", "1 in"), ("mm", "25.4 mm"), ("points", "72 pt"), ("picas", "6 pica"), ("percent", "50 %")] {
app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap();
let d = open(&mut app, &ctx);
assert_eq!(app.ui.dialog_mut(d).unwrap().fields["position"], shown, "{unit}");
crate::dialogs::cancel(&mut app, d).unwrap();
}
}
+7 -13
View File
@@ -159,17 +159,6 @@ fn guide_segment(xf: &ViewXform, clip: Rect, vertical: bool, pos: f64) -> [Pos2;
if vertical { [xf.to_screen(pos, lo), xf.to_screen(pos, hi)] } else { [xf.to_screen(lo, pos), xf.to_screen(hi, pos)] }
}
pub(crate) fn parse_guide_color(value: &str) -> Option<Color32> {
let value = value.strip_prefix('#')?;
if value.len() != 6 {
return None;
}
let r = u8::from_str_radix(value.get(0..2)?, 16).ok()?;
let g = u8::from_str_radix(value.get(2..4)?, 16).ok()?;
let b = u8::from_str_radix(value.get(4..6)?, 16).ok()?;
Some(Color32::from_rgb(r, g, b))
}
pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) {
let clip = painter.clip_rect();
let drag = app.guide_drag;
@@ -182,18 +171,23 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
let [a, b] = guide_segment(xf, clip, vertical, pos);
styled_line(painter, a, b, Stroke::new(1.0, color), style);
};
// A guide given a colour in New Guide keeps it; the rest use the preference colour (or, while
// New Guide Layout previews, the colour it will set).
let own = |vertical: bool, p: f64| guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b));
if app.ui.extras.guides || preview.is_some() {
for (vertical, list) in [(true, &guides.vertical), (false, &guides.horizontal)] {
for (i, p) in list.iter().enumerate() {
if drag.is_some_and(|d| d.vertical == vertical && d.index == Some(i)) {
continue;
}
line(vertical, *p, guide);
line(vertical, *p, own(vertical, *p));
}
}
}
if let Some(d) = drag {
line(d.vertical, d.pos, guide);
// A guide being moved shows its own colour; a new one dragged from a ruler the preference's.
let list = if d.vertical { &doc.guides.vertical } else { &doc.guides.horizontal };
line(d.vertical, d.pos, d.index.and_then(|i| list.get(i)).map_or(guide, |p| own(d.vertical, *p)));
}
}