mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Merge PR #9876: New Guide: add a position unit selector for pixels, millimeters and inches
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user