Ruler units keep their own decimal places (#2434) (#2476)

* Ruler units keep their own decimal places (#2434)

Document sizes were shown with one decimal place whatever the unit, so a
1234 px side at 300 ppi read 4.1 in instead of 4.113 in, and millimetres,
centimetres and picas lost a place too.

`Unit::decimals` now follows Photoshop: whole pixels, one place for points
and percent, two for millimetres, centimetres and picas, three for inches.
`fmt_decimals` rounds half away from zero and drops trailing zeros, so a
value is never shown with more precision than it has: 0.7995 in reads 0.8,
never 0.799 or 0.800, and a whole number keeps no trailing .0. It guards the
zero-decimal case so 550 px cannot lose its zero.

The Info panel readout, Image Size, Canvas Size, New Document and the shape
dialog all take their precision from that one place, through the new
`widgets::value_field_prec` and `widgets::unit_decimals`.

Percent keeps its single place: it was not reported and reads better coarse.

* Pass the new decimals argument at the remaining value_field_in call (test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jason
2026-10-10 20:34:49 +02:00
committed by GitHub
parent 0c907fb7f9
commit f8ea3d8423
6 changed files with 123 additions and 20 deletions
+24 -5
View File
@@ -149,12 +149,15 @@ impl Unit {
Unit::Percent => "%",
}
}
/// Decimal places a readout in this unit needs.
/// Decimal places a readout in this unit needs, following Photoshop: whole pixels, one place
/// for points and percent, two for millimetres, centimetres and picas, three for inches
/// (#2434). [`fmt_decimals`] rounds to this many places and drops trailing zeros.
pub fn decimals(self) -> usize {
match self {
Unit::Pixels => 0,
Unit::Points | Unit::Percent | Unit::Millimeters => 1,
_ => 2,
Unit::Points | Unit::Percent => 1,
Unit::Millimeters | Unit::Centimeters | Unit::Picas => 2,
Unit::Inches => 3,
}
}
}
@@ -698,11 +701,27 @@ impl Default for UnitsAndRulers {
}
}
/// Rounds `v` to `decimals` places (half away from zero) and drops trailing zeros, so a readout
/// never shows more precision than it has: 0.7995 at three places is `0.8`, never `0.799` or
/// `0.800`, and a whole number keeps no `.0` (#2434).
pub fn fmt_decimals(v: f64, decimals: usize) -> String {
let p = 10f64.powi(decimals as i32);
let r = (v * p).round() / p;
// -0.0 would format as "-0".
let r = if r == 0.0 { 0.0 } else { r };
let s = format!("{r:.*}", decimals);
// Only a fractional part may lose zeros: "550" must not become "55".
if !s.contains('.') {
return s;
}
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
impl UnitsAndRulers {
/// Format a length in document pixels in the ruler unit, e.g. `"2.50 in"`.
/// Format a length in document pixels in the ruler unit, e.g. `"2.5 in"`.
pub fn format(&self, px: f64, dpi: f64, extent: f64) -> String {
let v = self.rulers.from_px(px, dpi, extent, self.point_size.per_inch());
format!("{:.*}", self.rulers.decimals(), v)
fmt_decimals(v, self.rulers.decimals())
}
}
+32
View File
@@ -521,3 +521,35 @@ fn prefs_set_validates_the_pressure_curve() {
}
assert_eq!(p.tools.pressure_curve, vec![[0.0, 0.1], [0.5, 0.7], [1.0, 1.0]], "a refused value changes nothing");
}
/// #2434: each unit's readout keeps Photoshop's precision, rounded half away from zero and
/// without trailing zeros, so 0.7995 in reads 0.8 rather than 0.799 or 0.800.
#[test]
fn ruler_units_round_to_their_own_decimal_places() {
use super::{Unit, fmt_decimals};
assert_eq!(Unit::Pixels.decimals(), 0);
assert_eq!(Unit::Points.decimals(), 1);
for u in [Unit::Millimeters, Unit::Centimeters, Unit::Picas] {
assert_eq!(u.decimals(), 2, "{u:?}");
}
assert_eq!(Unit::Inches.decimals(), 3);
// Rounded to the unit's places, then trailing zeros dropped.
assert_eq!(fmt_decimals(0.7995, 3), "0.8");
assert_eq!(fmt_decimals(0.799, 3), "0.799");
assert_eq!(fmt_decimals(0.799, 2), "0.8");
assert_eq!(fmt_decimals(2.0, 3), "2");
assert_eq!(fmt_decimals(1234.5678, 2), "1234.57");
// Half away from zero, both signs, and no "-0".
assert_eq!(fmt_decimals(0.125, 2), "0.13");
assert_eq!(fmt_decimals(-0.125, 2), "-0.13");
assert_eq!(fmt_decimals(-0.004, 2), "0");
// A whole number keeps its zeros: 550 px is not 55.
assert_eq!(fmt_decimals(550.0, 0), "550");
// A 1500 px side at 300 dpi is 5 in / 12.7 cm / 127 mm / 360 pt / 30 pica.
let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() };
assert_eq!(r.format(1500.0, 300.0, 1500.0), "5");
let r = super::UnitsAndRulers { rulers: Unit::Centimeters, ..Default::default() };
assert_eq!(r.format(1500.0, 300.0, 1500.0), "12.7");
let r = super::UnitsAndRulers { rulers: Unit::Inches, ..Default::default() };
assert_eq!(r.format(100.0, 300.0, 1500.0), "0.333");
}
+2 -2
View File
@@ -442,7 +442,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
small_label(ui, tl!("Width"));
ui.horizontal(|ui| {
let mut w = shown_size(f, "width", 1920.0, &unit, ppi);
if widgets::value_field(ui, &mut w, 0.01..=300_000.0, "", 110.0).changed() {
if widgets::value_field_prec(ui, &mut w, 0.01..=300_000.0, "", 110.0, widgets::unit_decimals(&unit)).changed() {
set_size(f, "width", w, &unit, ppi);
}
let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect();
@@ -454,7 +454,7 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
small_label(ui, tl!("Height"));
ui.horizontal(|ui| {
let mut h = shown_size(f, "height", 1080.0, &unit, ppi);
if widgets::value_field(ui, &mut h, 0.01..=300_000.0, "", 110.0).changed() {
if widgets::value_field_prec(ui, &mut h, 0.01..=300_000.0, "", 110.0, widgets::unit_decimals(&unit)).changed() {
set_size(f, "height", h, &unit, ppi);
}
ui.add_space(6.0);
+2 -1
View File
@@ -173,7 +173,8 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
let px = num(f, key).unwrap_or(100.0);
let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32;
let mut v = to_unit(px);
if crate::widgets::value_field(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0).changed() {
let places = unit.decimals() as u32;
if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() {
let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX);
f.insert(key.into(), json!((px * 1000.0).round() / 1000.0));
}
+2 -1
View File
@@ -142,7 +142,8 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, orig_key:
let px = num(f, key);
let mut v = to_unit(px, &unit, orig, res) as f32;
let range = if unit == "px" { -300000.0..=300000.0 } else { -30000.0..=30000.0 };
let changed = crate::widgets::value_field(ui, &mut v, range, "", 90.0).changed();
// Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434).
let changed = crate::widgets::value_field_prec(ui, &mut v, range, "", 90.0, crate::widgets::unit_decimals(&unit)).changed();
if changed {
let px = from_unit(v as f64, &unit, orig, res);
f.insert(key.into(), json!(if unit == "px" { px.round() } else { px }));
+61 -11
View File
@@ -210,12 +210,43 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
/// Monospace numeric field with a dimmed unit suffix, Photoshop style. Drag to scrub.
pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32) -> Response {
value_field_in(ui, value, range, suffix, width, 0.0).0
value_field_in(ui, value, range, suffix, width, 0.0, None).0
}
/// Decimal places for a size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`).
/// The places themselves live on [`photocraft_engine::prefs::Unit`], so the dialogs, the Info
/// panel and the rulers agree (#2434).
pub fn unit_decimals(key: &str) -> u32 {
use photocraft_engine::prefs::Unit;
let unit = match key {
"in" => Unit::Inches,
"cm" => Unit::Centimeters,
"mm" => Unit::Millimeters,
"pt" => Unit::Points,
"pica" => Unit::Picas,
"percent" => Unit::Percent,
_ => Unit::Pixels,
};
unit.decimals() as u32
}
/// A [`value_field`] showing `decimals` decimal places instead of the one (or two, for a small
/// range) it picks itself: the ruler units need their own precision (#2434).
pub fn value_field_prec(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32, decimals: u32) -> Response {
value_field_in(ui, value, range, suffix, width, 0.0, Some(decimals)).0
}
/// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and
/// returns the box with the number's response.
fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32, trailing: f32) -> (Response, Rect) {
fn value_field_in(
ui: &mut Ui,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
suffix: &str,
width: f32,
trailing: f32,
decimals: Option<u32>,
) -> (Response, Rect) {
let t = Tokens::get(ui.ctx());
let (rect, slot) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover());
surface(ui, rect, t.field, false);
@@ -242,7 +273,7 @@ fn value_field_in(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<
ui.style_mut().override_font_id = Some(theme::mono(12.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let layout = egui::Layout::centered_and_justified(ui.layout().main_dir());
ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine)).inner
ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals)).inner
})
.inner
}
@@ -291,7 +322,7 @@ pub struct PopupFieldResponse {
pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32) -> PopupFieldResponse {
let t = Tokens::get(ui.ctx());
let (lo, hi) = (*range.start(), *range.end());
let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W);
let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W, None);
let arrow = Rect::from_min_max(pos2(rect.right() - POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - vec2(2.0, 2.0));
let resp = ui.interact(arrow, field.id.with("popup-slider"), Sense::click_and_drag());
let name = name.trim_end_matches([':', ':']).to_string();
@@ -382,7 +413,7 @@ pub fn focus_first_field(ctx: &egui::Context, on: bool) {
/// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for
/// Enter, Tab or click-away, because per keystroke `5/2` would land first and a caller that
/// rounds it would cut `5/2*2` short. `changed()` means a new value, not just a keystroke.
fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, fine: bool) -> Response {
fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, fine: bool, decimals: Option<u32>) -> Response {
let (id, ctx) = (ui.next_auto_id(), ui.ctx().clone());
// Focused before the DragValue is drawn, so it gains focus this frame and selects its text.
if ui.data_mut(|d| d.remove_temp::<bool>(first_field_id())).unwrap_or(false) {
@@ -398,7 +429,11 @@ fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32
egui::DragValue::new(value)
.range(range)
.speed(if fine { 0.01 } else { 0.5 })
.custom_formatter(move |v, _| if fine { fmt_num2(v) } else { fmt_num(v) })
.custom_formatter(move |v, _| match decimals {
Some(d) => fmt_num_prec(v, d),
None if fine => fmt_num2(v),
None => fmt_num(v),
})
.update_while_editing(!math)
// Focus is read when parsing, not above: Tab hands it on inside `ui.add`.
.custom_parser(move |s| {
@@ -1135,14 +1170,19 @@ pub fn chevron_icon(ui: &Ui, rect: Rect, visuals: &egui::style::WidgetVisuals, _
/// Photoshop-style numbers: "100", "12.5" (never "100.0").
pub fn fmt_num(v: f64) -> String {
let r = (v * 10.0).round() / 10.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r:.1}") }
fmt_num_prec(v, 1)
}
/// Rounds `v` to `decimals` places (half away from zero) and drops trailing zeros, so a value is
/// never shown with more precision than it has: `0.7995` at three places is `0.8`, `2.0` is `2`
/// (#2434). [`photocraft_engine::prefs::Unit::decimals`] gives each ruler unit's places.
pub fn fmt_num_prec(v: f64, decimals: u32) -> String {
photocraft_engine::prefs::fmt_decimals(v, decimals as usize)
}
/// Two-decimal variant of [`fmt_num`] for small ranges: `1.05`, `0.78`, `2`.
pub fn fmt_num2(v: f64) -> String {
let r = (v * 100.0).round() / 100.0;
format!("{r:.2}").trim_end_matches('0').trim_end_matches('.').to_string()
fmt_num_prec(v, 2)
}
/// Parses a typed number or bounded arithmetic, including parentheses and powers.
@@ -1223,7 +1263,7 @@ mod tests {
fn value_field_digits_sit_one_point_above_centre() {
let mut h = Harness::new_ui_state(
|ui, s: &mut (f32, egui::Rect)| {
s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0).1;
s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, None).1;
},
(50.0, egui::Rect::NOTHING),
);
@@ -1433,6 +1473,16 @@ mod tests {
#[test]
fn numbers_drop_trailing_zero() {
// #2434: the unit keys map to Photoshop's places, and the shared formatter trims zeros.
assert_eq!(super::unit_decimals("px"), 0);
assert_eq!(super::unit_decimals("pt"), 1);
assert_eq!(super::unit_decimals("mm"), 2);
assert_eq!(super::unit_decimals("cm"), 2);
assert_eq!(super::unit_decimals("pica"), 2);
assert_eq!(super::unit_decimals("in"), 3);
assert_eq!(super::unit_decimals("nonsense"), 0);
assert_eq!(super::fmt_num_prec(0.7995, 3), "0.8");
assert_eq!(super::fmt_num_prec(2.5, 0), "3");
assert_eq!(super::fmt_num(100.0), "100");
assert_eq!(super::fmt_num(12.46), "12.5");
assert_eq!(super::fmt_num(-3.0), "-3");