Merge PR #9876: New Guide: add a position unit selector for pixels, millimeters and inches

This commit is contained in:
David Mann
2026-11-06 14:53:38 +01:00
8 changed files with 296 additions and 13 deletions
+1
View File
@@ -99,6 +99,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/new-guide-units-*.png` | New Guide position unit selector, before and after (no artwork) | PhotoCraft contributors | Original PhotoCraft screenshot and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
+1 -1
View File
@@ -335,7 +335,7 @@ fn command_body_more(app: &mut PhotocraftApp, ui: &mut egui::Ui, d: &Dialog, fie
_ if crate::adjust_dialog::owns(fields) => crate::adjust_dialog::body(app, ui, fields),
_ if crate::blur_pins::owns(fields) => crate::blur_pins::body(ui, fields),
_ if fields.contains_key("__filter") => filter_body(app, ui, d, fields, id),
_ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(ui, fields),
_ if crate::new_guide_ui::owns(fields) => crate::new_guide_ui::body(app, ui, fields),
_ if fields.contains_key("__form") => {
if fields.get("__command").and_then(Value::as_str) == Some("file.fileInfo") {
let room = (ui.ctx().content_rect().height() - DIALOG_CHROME).max(120.0);
+3 -1
View File
@@ -104,7 +104,9 @@ fn prepare(app: &PhotocraftApp, draft: &mut Draft, fields: &Map<String, Value>)
let mut params = crate::new_guide_ui::params(app, fields);
// Opening a fractional position in cm/percent rounds its display. Leaving the text and
// orientation alone must not move the guide or consume Redo through that rounding.
if fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position)
// Once the user edits Position, even a value equal to the original readout is intentional.
if !fields.get("__positionEdited").and_then(Value::as_bool).unwrap_or(false)
&& fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position)
&& (params.get("newOrientation").and_then(Value::as_str) == Some("vertical")) == draft.edit.original().vertical
{
params["position"] = serde_json::json!(draft.edit.original().position);
+96 -10
View File
@@ -2,9 +2,10 @@
//! 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
//! "50%", "2.5 cm", "1in", "12 pt", "3 pc", "100 px". The unit dropdown converts the current
//! position without moving the guide; a bare number uses the selected unit. An explicit suffix
//! overrides that selection. 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
@@ -36,6 +37,19 @@ 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";
const UNIT: &str = "__unit";
/// Exact pixels behind an unedited ruler readout, also retained across unit changes.
const EXACT_POSITION: &str = "__exactPosition";
const UNITS: [(Unit, &str); 7] = [
(Unit::Pixels, "Pixels"),
(Unit::Millimeters, "Millimeters"),
(Unit::Inches, "Inches"),
(Unit::Centimeters, "Centimeters"),
(Unit::Points, "Points"),
(Unit::Picas, "Picas"),
(Unit::Percent, "Percent"),
];
/// A colour's label in the list (the catalogue holds each one).
pub(crate) fn color_label(name: &str) -> &'static str {
@@ -76,6 +90,37 @@ fn ruler_unit(app: &PhotocraftApp) -> Unit {
app.session.prefs().units_and_rulers.rulers
}
fn selected_unit(app: &PhotocraftApp, f: &Map<String, Value>) -> Unit {
f.get(UNIT).and_then(Value::as_str).and_then(Unit::from_suffix).unwrap_or_else(|| ruler_unit(app))
}
/// Only a text edit releases the exact position. Turning the guide still uses the usual
/// orientation-dependent interpretation of its displayed text (notably for percent).
fn exact_position(f: &Map<String, Value>, position: &str, orientation: &str) -> Option<f64> {
let saved = f.get(EXACT_POSITION)?;
if saved.get("text")?.as_str()? != position || f.get(EDIT)?.get("orientation")?.as_str()? != orientation {
return None;
}
saved.get("pixels")?.as_f64().filter(|px| px.is_finite())
}
/// Keep subpixel positions when switching units: ruler readout rounding would move the guide.
/// Invalid drafts stay untouched for the engine to reject on OK.
fn convert_position(app: &PhotocraftApp, position: &str, from: Unit, to: Unit, vertical: bool) -> Option<String> {
let (value, suffix) = Unit::parse_length(position)?;
let doc = &app.session.active()?.doc;
let dpi = f64::from(doc.resolution_dpi);
let extent = f64::from(if vertical { doc.size.width } else { doc.size.height });
let ppi = app.session.prefs().units_and_rulers.point_size.per_inch();
let px = suffix.unwrap_or(from).to_px(value, dpi, extent, ppi);
let converted = to.from_px(px, dpi, extent, ppi);
if !px.is_finite() || !converted.is_finite() {
return None;
}
// Display preserves the f64 value without exponent notation, which parse_length rejects.
Some(with_unit(&converted.to_string(), to))
}
/// "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 {
@@ -108,6 +153,7 @@ pub fn open(app: &mut PhotocraftApp) -> u64 {
f.insert("__label".into(), json!("New Guide…"));
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(position));
f.insert(UNIT.into(), json!(ruler_unit(app).suffix()));
f.insert("color".into(), json!(color));
f.insert("customColor".into(), json!(custom));
app.ui.open_dialog(DialogKind::Command, f)
@@ -144,7 +190,10 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result<u64, String> {
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)));
let position = in_ruler_unit(app, px, vertical);
f.insert("position".into(), json!(position));
f.insert(EXACT_POSITION.into(), json!({"text": position, "pixels": px}));
f.insert(UNIT.into(), json!(ruler_unit(app).suffix()));
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));
@@ -157,9 +206,10 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result<u64, String> {
}
/// 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>) {
pub fn body(app: &PhotocraftApp, 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 unit = selected_unit(app, f);
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);
@@ -173,7 +223,38 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
});
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.horizontal(|ui| {
let response = ui.add(egui::TextEdit::singleline(&mut position).desired_width(120.0)).labelled_by(position_label.id);
if ui.is_enabled() {
crate::field_tab::register(ui.ctx(), response.id);
}
if response.gained_focus() {
crate::field_tab::select_all(ui.ctx(), response.id, &position);
}
if f.contains_key(EDIT)
&& (response.changed()
|| f.get(EXACT_POSITION).and_then(|saved| saved.get("text")).and_then(Value::as_str).is_some_and(|text| text != position))
{
f.remove(EXACT_POSITION);
f.insert("__positionEdited".into(), json!(true));
}
if let Some((_, Some(typed_unit))) = Unit::parse_length(&position) {
unit = typed_unit;
}
let previous = unit;
if crate::widgets::dropdown(ui, "new-guide-unit", tl!("Units"), &mut unit, &UNITS, 110.0) {
let exact = exact_position(f, &position, &orientation);
let source = exact.map(|px| with_unit(&px.to_string(), Unit::Pixels));
if let Some(converted) = convert_position(app, source.as_deref().unwrap_or(&position), previous, unit, orientation == "vertical") {
if exact.is_some()
&& let Some(saved) = f.get_mut(EXACT_POSITION).and_then(Value::as_object_mut)
{
saved.insert("text".into(), json!(converted));
}
position = converted;
}
}
});
ui.end_row();
ui.label(tl!("Color"));
ui.horizontal(|ui| {
@@ -195,18 +276,23 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
});
f.insert("orientation".into(), json!(orientation));
f.insert("position".into(), json!(position));
f.insert(UNIT.into(), json!(unit.suffix()));
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".
/// The command's parameters: a bare number gets the selected unit; anything else goes as typed, for
/// the engine to read (or refuse, keeping the dialog open). An unedited guide keeps its exact
/// pixels behind the readout. 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 raw = f.get("position").and_then(Value::as_str).unwrap_or_default();
let typed = raw.trim();
let position = match Unit::parse_length(typed) {
Some((_, None)) => with_unit(typed, ruler_unit(app)),
Some((_, None)) => with_unit(typed, selected_unit(app, f)),
_ => typed.to_string(),
};
// Sending the retained pixels also avoids a round-trip conversion consuming Redo on OK.
let position = exact_position(f, raw, orientation).map_or_else(|| json!(position), |px| json!(px));
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) {
@@ -5,8 +5,12 @@ 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) {
app_at_resolution(unit, 100)
}
fn app_at_resolution(unit: &str, dpi: u32) -> (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("file.new", json!({"width": 400, "height": 200, "resolution": dpi})).unwrap();
app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": unit})).unwrap();
app.sync_views();
(app, egui::Context::default())
@@ -154,6 +158,186 @@ fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() {
}
}
#[test]
fn bare_positions_use_the_selected_unit_at_the_document_resolution() {
for (unit, position, expected) in [("px", "12,5", 12.5), ("mm", "25,4", 300.0), ("in", "1,5", 450.0)] {
let (mut app, ctx) = app_at_resolution("pixels", 300);
let d = open(&mut app, &ctx);
set(&mut app, d, "vertical", position);
app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!(unit));
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app), (vec![], vec![expected]), "{position} {unit}");
}
}
#[test]
fn unit_conversion_preserves_fractional_negative_positions_and_percent_extents() {
let (app, _) = app_at_resolution("pixels", 300);
let mut position = "-123.456789 px".to_string();
let mut from = Unit::Pixels;
for to in [Unit::Millimeters, Unit::Inches, Unit::Pixels] {
position = convert_position(&app, &position, from, to, true).unwrap();
from = to;
}
assert!((Unit::parse_length(&position).unwrap().0 + 123.456789).abs() < 1e-8, "{position}");
assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, true).unwrap(), "200 px");
assert_eq!(convert_position(&app, "50%", Unit::Pixels, Unit::Pixels, false).unwrap(), "100 px");
for invalid in ["", "-", "12 furlongs", "NaN", "inf"] {
assert!(convert_position(&app, invalid, Unit::Pixels, Unit::Inches, true).is_none());
}
}
#[test]
fn switching_units_and_confirming_preserves_high_precision_positions() {
use egui_kittest::{Harness, kittest::Queryable};
for original in [-1e-10_f64, 1e-10, -1e-100, 1e-100, -123.45678901234567, 0.0, 100.0, 123.45678901234567] {
let (app, ctx) = app_at_resolution("pixels", 300);
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
let d = open(h.state_mut(), &ctx);
set(h.state_mut(), d, "vertical", &format!("{original} px"));
h.run_steps(3);
for label in ["Inches", "Millimeters", "Pixels"] {
h.get_by_label("Units").click();
h.run_steps(3);
h.get_by_label(label).click();
h.run_steps(3);
}
crate::dialogs::confirm(h.state_mut(), d).unwrap();
let (horizontal, vertical) = guides(h.state());
assert!(horizontal.is_empty());
assert_eq!(vertical.len(), 1);
let actual = vertical[0];
// Allow floating-point conversion error, but no loss from decimal-place rounding.
let tolerance = original.abs() * f64::EPSILON * 8.0;
assert!((actual - original).abs() <= tolerance, "{original} px became {actual} px");
}
}
#[test]
fn editing_an_existing_guide_accepts_the_selected_unit_and_undo_restores_it() {
let (mut app, _) = app("pixels");
app.run(COMMAND, json!({"orientation": "horizontal", "position": 10})).unwrap();
crate::guide_select::select(&mut app, false, 0);
let d = open_edit(&mut app).unwrap();
set(&mut app, d, "vertical", "25,4");
app.ui.dialog_mut(d).unwrap().fields.insert(UNIT.into(), json!("mm"));
crate::guide_edit::sync(&mut app);
let preview = edit_preview(&app, &app.session.active().unwrap().doc).unwrap();
assert!(preview.horizontal.is_empty());
assert_eq!(preview.vertical, vec![100.0]);
assert_eq!(guides(&app), (vec![10.0], vec![]), "the selected unit changes only the preview until OK");
crate::dialogs::confirm(&mut app, d).unwrap();
assert_eq!(guides(&app), (vec![], vec![100.0]));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(guides(&app), (vec![10.0], vec![]));
}
#[test]
fn edit_unit_changes_preserve_the_exact_position_preview_and_redo() {
use egui_kittest::{Harness, kittest::Queryable};
for (rulers, original) in [("pixels", 12.5), ("cm", 123.45678901234567), ("percent", -1e-100)] {
let (mut app, _) = app(rulers);
app.run(COMMAND, json!({"orientation": "vertical", "position": original})).unwrap();
app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": original + 1.0})).unwrap();
assert!(app.session.undo());
crate::guide_select::select(&mut app, true, 0);
let history = app.session.active().unwrap().history.past_len();
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
let d = open_edit(h.state_mut()).unwrap();
if rulers == "pixels" {
assert_eq!(h.state_mut().ui.dialog_mut(d).unwrap().fields["position"], "13 px");
}
h.run_steps(3);
for label in ["Millimeters", "Inches", "Percent", "Pixels", "Millimeters"] {
h.get_by_label("Units").click();
h.run_steps(3);
h.get_by_label(label).click();
h.run_steps(3);
crate::guide_edit::sync(h.state_mut());
let preview = edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap();
assert_eq!(preview.vertical, vec![original], "{rulers} to {label}");
assert_eq!(guides(h.state()).1, vec![original]);
}
crate::dialogs::confirm(h.state_mut(), d).unwrap();
assert_eq!(guides(h.state()).1, vec![original]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
assert!(h.state().session.active().unwrap().history.can_redo(), "unit changes must remain a no-op");
}
}
#[test]
fn editing_the_position_after_a_unit_change_releases_the_exact_original() {
use egui_kittest::{Harness, kittest::Queryable};
let (mut app, _) = app("pixels");
app.run(COMMAND, json!({"orientation": "vertical", "position": 12.5})).unwrap();
crate::guide_select::select(&mut app, true, 0);
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
let d = open_edit(h.state_mut()).unwrap();
h.run_steps(3);
h.get_by_label("Units").click();
h.run_steps(3);
h.get_by_label("Millimeters").click();
h.run_steps(3);
h.get_by_label("Position").click();
h.run_steps(3);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("13 px".into()));
h.run_steps(3);
crate::guide_edit::sync(h.state_mut());
assert_eq!(edit_preview(h.state(), &h.state().session.active().unwrap().doc).unwrap().vertical, vec![13.0]);
for label in ["Inches", "Pixels"] {
h.get_by_label("Units").click();
h.run_steps(3);
h.get_by_label(label).click();
h.run_steps(3);
}
crate::dialogs::confirm(h.state_mut(), d).unwrap();
assert_eq!(guides(h.state()).1, vec![13.0]);
assert!(h.state_mut().session.undo());
assert_eq!(guides(h.state()).1, vec![12.5]);
}
#[test]
fn unit_dropdown_converts_the_draft_and_bare_typing_uses_its_selection() {
use egui_kittest::{Harness, kittest::Queryable};
let (app, ctx) = app_at_resolution("pixels", 300);
let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium);
let d = open(h.state_mut(), &ctx);
set(h.state_mut(), d, "vertical", "300 px");
h.run_steps(3);
for (label, position, suffix) in [("Millimeters", "25.4 mm", "mm"), ("Inches", "1 in", "in"), ("Pixels", "300 px", "px")] {
h.get_by_label("Units").click();
h.run_steps(3);
h.get_by_label(label).click();
h.run_steps(3);
let fields = &h.state().ui.dialogs.iter().find(|dialog| dialog.id == d).unwrap().fields;
assert_eq!(fields["position"], position);
assert_eq!(fields[UNIT], suffix);
assert_eq!(guides(h.state()), (vec![], vec![]), "changing units only edits the draft");
}
// An explicit typed suffix also updates the dropdown; Tab returns to the position field.
set(h.state_mut(), d, "vertical", "25.4 mm");
h.run_steps(3);
assert_eq!(h.get_by_label("Units").value().as_deref(), Some("Millimeters"));
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert!(h.get_by_label("Position").is_focused());
h.event(egui::Event::Text("12,7".into()));
h.run_steps(3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(guides(h.state()), (vec![], vec![150.0]));
assert!(h.state().ui.dialogs.is_empty());
}
// These regressions exercise the ordinary dialog handlers, not a separate model.
/// Open a draft on the first of two guides and propose moving it to 30 px.
fn edit_fixture() -> (PhotocraftApp, u64) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+10
View File
@@ -45,6 +45,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor
| Match | Module | Role |
|---|---|---|
| `__guideLayout` | `guide_layout_ui.rs` | View › New Guide Layout… |
| `__newGuide` | `new_guide_ui.rs` | New Guide / Edit Selected Guides; position with a unit dropdown |
| `__fill` | `fill_ui.rs` | Edit › Fill… |
| `__patternFill` | `pattern_fill_ui.rs` | Pattern Fill layer / New Fill Layer › Pattern… |
| `__stroke` | `stroke_ui.rs` | Edit › Stroke… |
@@ -74,6 +75,13 @@ actions with their marker already set.
### Shared private keys
New Guide and Edit Selected Guides open in the ruler unit and offer Pixels, Millimeters, Inches,
Centimeters, Points, Picas and Percent beside Position. Selecting a unit converts the draft using
the active document's resolution without moving the guide. Bare numbers use the selected unit
(`__unit`, a suffix such as `px`, `mm` or `in`); a typed suffix overrides it. Decimal commas are
accepted. Percent uses width for vertical guides and height for horizontal guides. OK dispatches
the existing guide command; Cancel discards the draft.
| Key | Meaning |
|---|---|
| `__command` | Engine command id run on OK (bare confirm, filters, sizing, …) |
@@ -97,6 +105,7 @@ actions with their marker already set.
| Key | Module |
|---|---|
| `__guideLayout` | `guide_layout_ui` |
| `__newGuide` | `new_guide_ui` |
| `__fill` | `fill_ui` |
| `__patternFill` | `pattern_fill_ui` |
| `__stroke` | `stroke_ui` |
@@ -216,6 +225,7 @@ Same hub entry as standalone pickers; `color_picker_ui::confirm` branches on ext
| Date | Change |
|---|---|
| 2026-10-27 | New Guide / Edit Selected Guides: visible position unit dropdown and resolution-aware conversion |
| 2026-10-11 | Initial doc from dialog codebase audit |
| 2026-10-11 | Document all `DialogKind` values and `Command` routing markers |
| 2026-10-11 | Alt+Cancel → Reset hub (`dialog_alt_reset`); opt-in markers |