Files
photocraft/crates/ui-egui/src/dialogs/new_guide_ui.rs
T
a3ea9a4277 Guides: integrate edit-session previews and interaction-check scenarios (#9190)
* feat(ui): integrate edit-session guide drafts and interaction-check scenarios

* fix(ui): repaint guide previews after automation field changes

* fix(guides): retain drafts after command refusal

* Restore the upstream Git-source check and keep guide replay local

* Document pending source approval and shared replay workflow

* Clarify that shared CI support for guide replays is pending

* Vendor the guide edit dependency for source review

Keep the edit-session revision and runtime behavior. Track its source, licenses, checksums and lifecycle tests in-tree in response to the source review in #9190. Apply the workspace lint policy to the standalone crate.

* Move edit-session into ui-egui as a module (drop vendor/)

The edit-session code now lives in crates/ui-egui/src/edit_session.rs,
with its tests in edit_session/tests.rs, following the project's module
layout. This removes vendor/edit-session, the path dependency, the
workspace member, the NOTICE entry and the xtask layers entry.

---------

Co-authored-by: Anton Pepperok <perchneko.anton@gmail.com>
Co-authored-by: Perchenko Anton <perchenkoanton@iMac-Perchenko.local>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 14:50:22 +01:00

294 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! Edit Selected Guides (the guide menu, `guide_select`) opens the same dialog on the selected
//! guide: its orientation, its position in the ruler unit and its colour. OK runs `view.editGuide`,
//! sending the colour only when it was changed, so a guide that uses the preference colour keeps
//! doing so.
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 EDIT_COMMAND: &str = "view.editGuide";
/// In an Edit Selected Guides dialog: the guide being edited, as `{"orientation", "index"}`, and
/// the colour it opened with.
const EDIT: &str = "__edit";
const INITIAL_COLOR: &str = "__initialColor";
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…"),
}
}
/// Accept the named guide colours and the explicit custom-colour choice.
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)
}
/// Identify the specialized New Guide or Edit Selected Guides dialog fields.
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
}
/// Read the ruler unit used for guide positions without an explicit suffix.
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)
}
/// The list entry (and Custom… colour) showing `rgb`: a named colour when it is one, else Custom….
fn color_choice(rgb: [u8; 3]) -> (String, String) {
let hex = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]);
match GUIDE_COLORS.iter().find(|(.., c)| *c == rgb) {
Some((name, ..)) => (name.to_string(), hex),
None => (CUSTOM.to_string(), hex),
}
}
/// Edit Selected Guides: the dialog on the selected guide, with its orientation, its position in
/// the current ruler unit and its colour (its own, or the preference colour it is drawn in).
pub fn open_edit(app: &mut PhotocraftApp) -> Result<u64, String> {
let (vertical, index) = crate::guide_select::selected(app).ok_or_else(|| "No guide is selected".to_string())?;
let (px, own) = app
.session
.active()
.and_then(|st| {
let g = &st.doc.guides;
let p = *if vertical { &g.vertical } else { &g.horizontal }.get(index)?;
Some((p, g.color(vertical, p)))
})
.ok_or_else(|| "No guide is selected".to_string())?;
let pref = photocraft_engine::prefs::parse_hex(&app.session.prefs().guides_grid_and_slices.guide_color).unwrap_or([0x4a, 0xff, 0xff]);
let (color, custom) = color_choice(own.unwrap_or(pref));
let orientation = if vertical { "vertical" } else { "horizontal" };
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(EDIT_COMMAND));
f.insert("__label".into(), json!("Edit Selected Guides"));
f.insert(EDIT.into(), json!({"orientation": orientation, "index": index}));
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(in_ruler_unit(app, px, vertical)));
f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() }));
f.insert("color".into(), json!(color));
f.insert("customColor".into(), json!(custom));
let id = app.ui.open_dialog(DialogKind::Command, f.clone());
if let Err(error) = crate::guide_edit::begin(app, id, &f) {
app.ui.close_dialog(id);
return Err(error);
}
Ok(id)
}
/// Edit the dialog fields and show validation errors without changing the live guide.
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();
let position_label = ui.label(tl!("Position"));
ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id);
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", tl!("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() };
match f.get(EDIT) {
Some(edit) => {
let mut p = json!({"orientation": edit["orientation"], "index": edit["index"], "newOrientation": orientation, "position": position});
if f.get(INITIAL_COLOR).and_then(Value::as_str) != Some(color.as_str()) {
p["color"] = json!(color);
}
p
}
None => json!({"orientation": orientation, "position": position, "color": color}),
}
}
/// Dispatch New Guide or guarded guide editing, retaining typed fields on a retryable error.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let params = params(app, f);
if f.contains_key(EDIT) {
return confirm_edit(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)
}
}
}
/// Edit Selected Guides' OK: the edited guide stays selected (at the end of the other list if it
/// turned). A refused edit reopens the dialog, as New Guide does.
fn confirm_edit(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let (params, result) = match crate::guide_edit::finish(app, f) {
Ok(Some(applied)) => applied,
Ok(None) => return Ok(Value::Null),
Err(error) => {
if crate::guide_edit::retryable(app) {
let mut again = f.clone();
again.insert("__error".into(), json!(error));
let id = app.ui.open_dialog(DialogKind::Command, again);
if let Some(draft) = app.guide_edit.as_mut() {
draft.dialog = id;
}
} else {
app.guide_edit = None;
}
return Err(error);
}
};
let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical");
let index = if from == to {
params["index"].as_u64().and_then(|i| usize::try_from(i).ok())
} else {
app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1))
};
if let Some(i) = index {
crate::guide_select::select(app, to, i);
}
Ok(result)
}
/// The validated draft shown on the canvas, without changing the document or its history.
pub fn edit_preview<'a>(app: &'a PhotocraftApp, doc: &photocraft_doc::Document) -> Option<&'a photocraft_doc::Guides> {
crate::guide_edit::preview(app, doc)
}
#[cfg(test)]
mod tests;