Files
photocraft/crates/engine/src/guide_layout.rs
T
FinnNkandClaude Opus 5.5 962e0a2f98 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:46:56 -04:00

187 lines
7.3 KiB
Rust

//! Pure guide-layout calculation shared by the command and its non-destructive UI preview.
use photocraft_doc::Guides;
use photocraft_geom::Rect;
use serde_json::Value;
use crate::{EngineError, Result, Session};
const COMMAND: &str = "view.newGuideLayout";
const MAX_COUNT: u64 = 1000;
const MAX_EDGES: u64 = 16_000;
fn bad(message: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: COMMAND.into(), msg: message.into() }
}
fn number(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).ok_or_else(|| bad(format!("{key} must be a finite non-negative number"))),
}
}
fn count(p: &Value, key: &str) -> Result<u64> {
let n = match p.get(key) {
None => 0,
Some(v) => v.as_u64().ok_or_else(|| bad(format!("{key} must be a whole number")))?,
};
// Each column/row costs a loop iteration plus a duplicate scan, so an
// absurd count from the caller would block the app for minutes (#704).
// Photoshop's own dialog caps at 32; 1000 is a generous ceiling that
// still finishes instantly.
if n > MAX_COUNT {
return Err(bad(format!("{key} must be {MAX_COUNT} or fewer (got {n})")));
}
Ok(n)
}
fn flag(p: &Value, key: &str) -> Result<bool> {
match p.get(key) {
None => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| bad(format!("{key} must be a boolean"))),
}
}
/// Document coordinates of every selected artboard, including boards containing selected layers.
pub fn selected_artboards(s: &Session) -> Vec<Rect> {
let Some(st) = s.active() else { return Vec::new() };
let selected = st.selected_layers();
let ids: std::collections::HashSet<_> = selected.iter().filter_map(|id| st.doc.artboard_of(*id)).collect();
st.doc.artboards().into_iter().filter(|(id, _, _)| ids.contains(id)).map(|(_, _, board)| board.rect).collect()
}
fn targets(s: &Session, p: &Value) -> Result<Vec<Rect>> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let target = match p.get("target") {
None => "document",
Some(v) => v.as_str().ok_or_else(|| bad("target must be a string"))?,
};
match target {
"document" => Ok(vec![st.doc.bounds()]),
"selectedArtboards" => {
let boards = selected_artboards(s);
if boards.is_empty() { Err(bad("select an artboard or a layer inside one")) } else { Ok(boards) }
}
_ => Err(bad("target must be document or selectedArtboards")),
}
}
fn push_unique(out: &mut Vec<f64>, value: f64) -> Result<()> {
if !value.is_finite() {
return Err(bad("guide coordinates exceed the supported range"));
}
if !out.iter().any(|x| (*x - value).abs() < 0.01) {
out.push(value);
}
Ok(())
}
/// Guide positions for `count` columns (or rows) across `[start, end]`, Photoshop-style: both
/// edges of every column; a fixed `width` packs columns from `start` (or centres them).
fn axis(start: f64, end: f64, count: u64, fixed: f64, gap: f64, center: bool, out: &mut Vec<f64>) -> Result<()> {
if count == 0 {
return Ok(());
}
let available = end - start;
let n = count as f64;
let width = if fixed > 0.0 { fixed } else { (available - (n - 1.0) * gap) / n };
let total = n * width + (n - 1.0) * gap;
if width <= 0.0 || !total.is_finite() || total > available + 0.001 {
return Err(bad("count, size and gutter must fit inside the target margins"));
}
let origin = if center && fixed > 0.0 { start + (available - total) / 2.0 } else { start };
for i in 0..count {
let a = origin + i as f64 * (width + gap);
push_unique(out, a)?;
push_unique(out, a + width)?;
}
Ok(())
}
/// New lines and the exact guide set that OK will commit. This never edits the session.
#[derive(Clone, Debug)]
pub struct Layout {
pub added: Guides,
pub guides: Guides,
}
pub fn calculate(s: &Session, p: &Value) -> Result<Layout> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let cols = count(p, "columns")?;
let rows = count(p, "rows")?;
let width = number(p, "width", 0.0)?;
let height = number(p, "height", 0.0)?;
let gap = number(p, "gutter", 0.0)?;
let row_gap = number(p, "rowGutter", gap)?;
let center = flag(p, "centerColumns")?;
let clear = flag(p, "clearExisting")?;
let margins = match p.get("margin") {
Some(Value::Array(a)) => {
if a.len() != 4 {
return Err(bad("margin must contain top, left, bottom and right"));
}
let mut m = [0.0; 4];
for (out, value) in m.iter_mut().zip(a) {
*out = value.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad("margins must be finite non-negative numbers"))?;
}
m
}
_ => [number(p, "margin", 0.0)?; 4],
};
let [top, left, bottom, right] = margins;
let bounds = targets(s, p)?;
// Bound the aggregate work as well as each count, including multi-artboard requests.
if (cols + rows + 4).saturating_mul(2).saturating_mul(bounds.len() as u64) > MAX_EDGES {
return Err(bad("the selected targets would create too many guide lines"));
}
let mut added = Guides::default();
for rect in &bounds {
let (x0, y0, x1, y1) = (f64::from(rect.x0) + left, f64::from(rect.y0) + top, f64::from(rect.x1) - right, f64::from(rect.y1) - bottom);
if x0 >= x1 || y0 >= y1 {
return Err(bad("margins must leave space inside the target"));
}
if margins.iter().any(|x| *x != 0.0) {
push_unique(&mut added.vertical, x0)?;
push_unique(&mut added.vertical, x1)?;
push_unique(&mut added.horizontal, y0)?;
push_unique(&mut added.horizontal, y1)?;
}
axis(x0, x1, cols, width, gap, center, &mut added.vertical)?;
axis(y0, y1, rows, height, row_gap, center, &mut added.horizontal)?;
}
if added.vertical.is_empty() && added.horizontal.is_empty() {
return Err(bad("give \"columns\", \"rows\" or a \"margin\""));
}
let mut guides = st.doc.guides.clone();
if clear {
if p.get("target").and_then(Value::as_str) == Some("selectedArtboards") {
// Guides are stored as document-wide axes, matching Clear Selected Artboard Guides.
guides.vertical.retain(|v| !bounds.iter().any(|r| *v >= f64::from(r.x0) && *v <= f64::from(r.x1)));
guides.horizontal.retain(|v| !bounds.iter().any(|r| *v >= f64::from(r.y0) && *v <= f64::from(r.y1)));
} 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)?;
}
for y in &added.horizontal {
push_unique(&mut guides.horizontal, *y)?;
}
Ok(Layout { added, guides })
}
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let layout = calculate(s, p)?;
let result = serde_json::json!({"vertical":layout.added.vertical.len(),"horizontal":layout.added.horizontal.len()});
s.edit("New Guide Layout", |doc, _| {
doc.guides = layout.guides;
Ok(())
})?;
Ok(result)
}