mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
973e7776ea
commit
962e0a2f98
35 files changed
+770
-50
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -281,7 +281,6 @@ pub fn has_dialog(command: &str) -> bool {
|
||||
|| matches!(
|
||||
command,
|
||||
"image.trim"
|
||||
| "view.newGuide"
|
||||
| "select.refineEdge"
|
||||
| "edit.assignProfile"
|
||||
| "edit.convertToProfile"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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;
|
||||
|
||||
@@ -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)}))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user