Free Transform: right-click W or H to pick their unit (#3235) (#3301)

As in Photoshop, right-clicking the W or H field of the Free Transform
(and Free Transform Path) options bar opens a menu of units: Pixels,
Inches, Centimeters, Millimeters, Points, Picas and Percent, the
current one checked. W and H then show the box's size in that unit at
the document's resolution (1200 px at 300 ppi is 10.16 cm), and a
value typed in it scales the box to that length; Percent, the default,
is the scale as before. The pick is remembered with the dialog
settings (`dialogs["edit.freeTransform"].sizeUnit`).

The menu is the rulers' unit menu (`rulers::unit_menu`), the
conversion is the Properties panel's `LenUnit`, and the size limits and
the link toggle work on the scale as before. Length fields are wider
(84 pt) so "2116.67 mm" fits; Percent keeps its 66 pt.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dan Hiorns 2026-10-11 12:40:14 -06:00 committed by GitHub
parent 76c7d723f4
commit bb5d26e052
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 153 additions and 10 deletions

View File

@ -70,9 +70,9 @@ fn unit_menu_items(current: Unit) -> Vec<(String, &'static str, bool)> {
UNITS.iter().map(|&u| (tl!(unit_label(u)).to_string(), u.name(), u == current)).collect()
}
/// Right-clicking a ruler offers the length units; the chosen one's name is returned (`None` if the
/// menu is dismissed without a pick).
fn ruler_unit_menu(ui: &mut egui::Ui, current: Unit) -> Option<&'static str> {
/// The length units menu that right-clicking a ruler (or Free Transform's W and H fields) opens;
/// the chosen unit's name is returned (`None` if the menu is dismissed without a pick).
pub(crate) fn unit_menu(ui: &mut egui::Ui, current: Unit) -> Option<&'static str> {
crate::widgets::menu_scroll(ui, |ui| {
ui.set_min_width(150.0);
let mut chosen = None;
@ -367,7 +367,7 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: &
finish_drag(app, d);
}
resp.context_menu(|ui| {
if let Some(name) = ruler_unit_menu(ui, unit) {
if let Some(name) = unit_menu(ui, unit) {
chosen_unit = Some(name);
}
});

View File

@ -1988,6 +1988,39 @@ fn scale_limit_pct(side: f64) -> f32 {
((f64::from(POSITION_LIMIT_PX) / side * 100.0) as f32).clamp(100.0, SCALE_LIMIT_PCT)
}
/// Where Free Transform remembers the unit of its W and H fields, picked by right-clicking either
/// of them as in Photoshop (Percent until another is picked).
const SIZE_UNIT_KEY: &str = "edit.freeTransform";
/// The W/H unit, with the document's resolution and the points per inch it converts with.
fn size_unit(app: &PhotocraftApp) -> crate::layer_props_ui::LenUnit {
use photocraft_engine::prefs::Unit;
let unit = app.session.prefs().dialogs.get(SIZE_UNIT_KEY).and_then(|v| v.get("sizeUnit")).and_then(serde_json::Value::as_str).and_then(Unit::parse);
crate::layer_props_ui::LenUnit { unit: unit.unwrap_or(Unit::Percent), ..crate::layer_props_ui::LenUnit::of(app) }
}
fn set_size_unit(app: &mut PhotocraftApp, name: &str) {
app.session.prefs.edit(|p| {
let entry = p.dialogs.entry(SIZE_UNIT_KEY.to_string()).or_insert_with(|| json!({}));
if !entry.is_object() {
*entry = json!({});
}
entry["sizeUnit"] = json!(name);
});
}
/// A W or H value shown in `u`: the box side `side` px wide at the original size, scaled by `pct`.
/// Percent stays the scale itself (100 % is the original size), lengths are the side's length.
fn size_shown(u: &crate::layer_props_ui::LenUnit, pct: f64, side: f64) -> f32 {
u.shown(pct / 100.0 * side, side)
}
/// The scale (%) a W or H value typed in `u` asks for, for a box side `side` px wide originally.
fn size_pct(u: &crate::layer_props_ui::LenUnit, v: f32, side: f64) -> f64 {
let side = side.abs().max(1e-9);
u.px(v, side) / side * 100.0
}
/// Options bar while transforming: the fields (reference point X/Y, W/H %, angle, interpolation,
/// or the warp controls), then the mode switch, cancel and commit right after them. A bar too
/// narrow for both pins the buttons to the right edge and clips the fields, so Commit and Cancel
@ -2033,10 +2066,18 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes
s.pivot = [px as f64, py as f64];
}
crate::widgets::vline(ui, 22.0);
let (mut w, mut h) = (sx as f32, sy as f32);
let (wlim, hlim) = (scale_limit_pct(t.rect[2] - t.rect[0]), scale_limit_pct(t.rect[3] - t.rect[1]));
// W and H in the unit picked from their right-click menu: the scale in percent, or the box's
// length (Photoshop's units, at the document's resolution).
let u = size_unit(app);
let (side_w, side_h) = (t.rect[2] - t.rect[0], t.rect[3] - t.rect[1]);
let (wlim, hlim) = (scale_limit_pct(side_w), scale_limit_pct(side_h));
let (wlim_u, hlim_u) = (size_shown(&u, f64::from(wlim), side_w), size_shown(&u, f64::from(hlim), side_h));
let (mut w, mut h) = (size_shown(&u, sx, side_w), size_shown(&u, sy, side_h));
let (suffix, places) = (u.unit.suffix(), u.unit.decimals() as u32);
// Lengths need more room than a percentage ("2116.67 mm").
let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 };
lbl(ui, "W:");
let rw = crate::widgets::value_field(ui, &mut w, -wlim..=wlim, "%", 66.0);
let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places);
let link_id = egui::Id::new("transform-link");
let mut link: bool = ui.data(|d| d.get_temp(link_id)).unwrap_or(true);
if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() {
@ -2044,7 +2085,15 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes
ui.data_mut(|d| d.insert_temp(link_id, link));
}
lbl(ui, "H:");
let rh = crate::widgets::value_field(ui, &mut h, -hlim..=hlim, "%", 66.0);
let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places);
for r in [&rw, &rh] {
r.context_menu(|ui| {
if let Some(name) = crate::rulers::unit_menu(ui, u.unit) {
set_size_unit(app, name);
}
});
}
let (w, h) = (size_pct(&u, w, side_w) as f32, size_pct(&u, h, side_h) as f32);
if rw.changed() || rh.changed() {
let (kx, ky) = if link {
let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) };
@ -2477,11 +2526,17 @@ mod tests {
/// Types `text` + Enter into the `n`th numeric field of the transform bar (X, Y, W, H, angle…).
fn type_into_bar_field(n: usize, text: &str) -> TransformSession {
type_into_bar_field_with(|_| {}, n, text)
}
/// The transform bar over the test box, after `setup` (e.g. picking the W/H unit). Tall enough
/// for the fields' unit menu to open below the bar unscrolled.
fn bar_harness(setup: impl FnOnce(&mut PhotocraftApp)) -> egui_kittest::Harness<'static, PhotocraftApp> {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.transform = Some(session());
let mut h = Harness::builder().with_size(vec2(900.0, 48.0)).build_ui_state(
setup(&mut app);
let mut h = Harness::builder().with_size(vec2(900.0, 360.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
@ -2492,6 +2547,13 @@ mod tests {
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(6);
h
}
/// [`type_into_bar_field`] after `setup`.
fn type_into_bar_field_with(setup: impl FnOnce(&mut PhotocraftApp), n: usize, text: &str) -> TransformSession {
use egui_kittest::kittest::Queryable;
let mut h = bar_harness(setup);
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(n).unwrap().click();
h.run();
for c in text.chars() {
@ -2503,6 +2565,87 @@ mod tests {
h.state().ui.transform.clone().unwrap()
}
/// The values the W and H fields show (the 3rd and 4th numeric fields).
fn shown_size(h: &egui_kittest::Harness<'static, PhotocraftApp>) -> Vec<String> {
use egui_kittest::kittest::{NodeT, Queryable};
h.get_all_by_role(egui::accesskit::Role::SpinButton).skip(2).take(2).map(|n| n.accesskit_node().value().unwrap_or_default().to_string()).collect()
}
#[test]
fn size_fields_convert_between_percent_and_lengths() {
use crate::layer_props_ui::LenUnit;
use photocraft_engine::prefs::Unit;
// A 300 px side at 300 ppi is one inch at 100 %.
let at = |unit| LenUnit { unit, dpi: 300.0, ppi: 72.0 };
for (unit, at_100) in [
(Unit::Percent, 100.0),
(Unit::Pixels, 300.0),
(Unit::Inches, 1.0),
(Unit::Centimeters, 2.54),
(Unit::Millimeters, 25.4),
(Unit::Points, 72.0),
(Unit::Picas, 6.0),
] {
let u = at(unit);
let shown = size_shown(&u, 100.0, 300.0);
assert!((f64::from(shown) - at_100).abs() < 1e-3, "{unit:?}: {shown}");
assert!((size_shown(&u, 150.0, 300.0) - shown * 1.5).abs() < 1e-3, "{unit:?} scales with the box");
assert!((size_pct(&u, shown * 2.0, 300.0) - 200.0).abs() < 1e-3, "{unit:?}: twice the length is 200 %");
assert!((size_pct(&u, -shown, 300.0) + 100.0).abs() < 1e-3, "{unit:?}: a negative length flips");
}
// A degenerate side never divides by zero.
assert!(size_pct(&at(Unit::Inches), 1.0, 0.0).is_finite());
}
#[test]
fn w_and_h_take_lengths_in_the_unit_picked_for_them() {
use photocraft_engine::prefs::Unit;
// The test box is 100 × 50 px; with no document open, lengths convert at 72 ppi.
let cm = |app: &mut PhotocraftApp| set_size_unit(app, Unit::Centimeters.name());
let h = bar_harness(cm);
assert_eq!(shown_size(&h), ["3.53", "1.76"], "the box's size in cm, not 100 %");
// 7.06 cm is twice the width; linked, the height follows.
let t = type_into_bar_field_with(cm, 2, "7.06");
let (sx, sy, _, _) = readout(&t);
assert!((sx - 200.0).abs() < 0.2 && (sy - 200.0).abs() < 0.2, "{sx} {sy}");
// Pixels: an exact width.
let t = type_into_bar_field_with(|app| set_size_unit(app, Unit::Pixels.name()), 2, "250");
assert!((t.quad[1][0] - t.quad[0][0] - 250.0).abs() < 0.01, "{:?}", t.quad);
// Percent is still the default.
assert_eq!(shown_size(&bar_harness(|_| {})), ["100", "100"]);
}
#[test]
fn right_clicking_w_or_h_picks_their_unit_and_remembers_it() {
use egui_kittest::kittest::Queryable;
let mut h = bar_harness(|_| {});
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(2).unwrap().click_secondary();
h.run_steps(2);
h.get_by_label("Inches").click();
h.run_steps(3);
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Inches);
assert_eq!(h.state().session.prefs().dialogs[SIZE_UNIT_KEY]["sizeUnit"], "inches");
assert_eq!(shown_size(&h), ["1.389", "0.694"], "100 × 50 px at 72 ppi");
// H opens the same menu, with the current unit checked.
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(3).unwrap().click_secondary();
h.run_steps(2);
assert!(h.query_by_label("✓ Inches").is_some(), "the current unit is checked");
h.get_by_label("Percent").click();
h.run_steps(3);
assert_eq!(shown_size(&h), ["100", "100"]);
// A hand-edited or stale preference falls back to Percent instead of failing.
h.state_mut().session.prefs.edit(|p| {
p.dialogs.insert(SIZE_UNIT_KEY.into(), json!({"sizeUnit": "furlongs"}));
});
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Percent);
h.state_mut().session.prefs.edit(|p| {
p.dialogs.insert(SIZE_UNIT_KEY.into(), json!("not an object"));
});
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Percent);
set_size_unit(h.state_mut(), "points");
assert_eq!(size_unit(h.state()).unit, photocraft_engine::prefs::Unit::Points, "a bad entry is replaced, not kept");
}
#[test]
fn editing_the_contents_of_a_dropped_file_keeps_the_layer() {
let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24));