Numeric fields: typed units, scrubby labels, double-click reset (#3099) (#3100)

* Numeric fields: typed units, scrubby labels, double-click reset (#3099)

- Value fields convert a unit typed after a number into the field's unit
  (1in, 2.54cm, 72pt, 1pica/pc, 10px, 50%) at the active document's
  resolution; Image Size and New Document use their own resolution. A
  unit that can't convert, or bad arithmetic, keeps the old value.
- widgets::scrub_label: dragging a label scrubs the value field right
  after it on its row (a step a point, Shift x10, Alt a tenth as fast),
  with a horizontal-resize cursor. Used by options-bar labels, slider
  rows and the Character panel icons. A slider row reports the scrub as
  one drag, so adjustment edits coalesce into one history step.
- Adjustment slider rows with a known default and filter dialog sliders
  reset on double-click, like Color Balance already did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Scrubby labels: drag-only, not focusable, so Tab skips them

Sense::drag() is focusable in egui 0.36, so every scrubby label became an
extra Tab stop and tab_focus_reveals_the_final_narrow_tool_option could no
longer reach the last tool option.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 19:09:08 -04:00
1 parent 661ba806d1
commit d706acdde9
11 files changed
+433 -37

No files matched your search

+3 -1
View File
@@ -194,7 +194,9 @@ fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[(&str, &str, f32, f32, f32,
let mut e = Edit::default();
for &(key, text, min, max, default, unit) in rows {
let mut x = num(v, key, default).clamp(min, max);
let r = widgets::slider_row(ui, text, &mut x, min..=max, unit, None);
// A double-click on the slider puts it back to its default (Photoshop).
let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None };
let r = widgets::slider_row_with(ui, text, &mut x, min..=max, unit, None, gestures);
if r.changed() {
v[key] = json!(if max - min <= 10.0 { round(x, 0.01) } else { x.round() });
}
+3 -1
View File
@@ -563,7 +563,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
}
ui.add_space(4.0);
} else {
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
// A double-click on the slider puts it back to the filter's default.
let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None };
crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures);
}
// Small ranges (0–1 thresholds and centres) keep the two decimals their field shows.
let scale = if max - min <= 10.0 { 100.0 } else { 10.0 };
+5
View File
@@ -1604,6 +1604,11 @@ impl eframe::App for PhotocraftApp {
// Each painting tool keeps its own brush (#218), so switch the active tool's brush in
// before anything this frame reads it (the cursor, the options bar, a stroke).
paint_mouse::sync_tool_brush(self);
// A unit typed into a value field (`1in` in a px field) converts at the active document's
// resolution, as in Photoshop.
let ppi = self.session.prefs().units_and_rulers.point_size.per_inch();
let dpi = self.session.active().map_or(72.0, |d| f64::from(d.doc.resolution_dpi.max(1.0)));
crate::widgets::set_field_resolution(&ctx, dpi, ppi);
// View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns).
screen_picker::show(&ctx);
if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) {
+4 -2
View File
@@ -466,10 +466,12 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
ui.add_space(8.0);
let ppi = get_f(f, "resolution", 72.0);
let mut unit = get_s(f, "__unit", "px");
// A typed unit (`210mm` while in pixels) converts at the new document's resolution.
let units = widgets::FieldUnits { unit: widgets::unit_of_key(&unit), dpi: f64::from(ppi), ppi: 72.0 };
small_label(ui, tl!("Width"));
ui.horizontal(|ui| {
let mut w = shown_size(f, "width", 1920.0, &unit, ppi);
if widgets::value_field_prec(ui, &mut w, 0.01..=300_000.0, "", 110.0, widgets::unit_decimals(&unit)).changed() {
if widgets::value_field_units(ui, &mut w, 0.01..=300_000.0, 110.0, widgets::unit_decimals(&unit), units).changed() {
set_size(f, "width", w, &unit, ppi);
}
let opts: Vec<(String, &str)> = UNITS.iter().map(|u| (u.0.to_string(), u.1)).collect();
@@ -481,7 +483,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_prec(ui, &mut h, 0.01..=300_000.0, "", 110.0, widgets::unit_decimals(&unit)).changed() {
if widgets::value_field_units(ui, &mut h, 0.01..=300_000.0, 110.0, widgets::unit_decimals(&unit), units).changed() {
set_size(f, "height", h, &unit, ppi);
}
ui.add_space(6.0);
+119 -1
View File
@@ -1,12 +1,47 @@
//! Bounded arithmetic parser shared by every editable numeric widget.
//! No scripting, allocations derived from results, or non-finite values.
use photocraft_engine::prefs::Unit;
pub(crate) fn parse(text: &str) -> Option<f64> {
parse_with_units(text, None)
}
/// What a unit typed after a number means in a field (Photoshop: `1in` in a px field is the
/// pixels of an inch at the document resolution).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Units<'s> {
/// The field's own unit, `None` for a field that isn't a length or a percentage.
pub unit: Option<Unit>,
/// The suffix the field shows; typed after a number it means the field's unit (`45°`).
pub suffix: &'s str,
/// Document resolution, pixels per inch.
pub dpi: f64,
/// Points per inch (72 PostScript, 72.27 traditional).
pub ppi: f64,
}
/// Unit names a number may carry, longest first so `pica` wins over `pc`.
const UNIT_NAMES: [(&str, Unit); 8] = [
("pica", Unit::Picas),
("px", Unit::Pixels),
("pt", Unit::Points),
("pc", Unit::Picas),
("in", Unit::Inches),
("cm", Unit::Centimeters),
("mm", Unit::Millimeters),
("%", Unit::Percent),
];
/// [`parse`] where a number may carry a unit (`2in`, `5 mm`, `50%`), converted into the field's
/// unit. A unit that can't convert (a length in a percent field, any unit in a unitless one) makes
/// the whole text invalid; without `units`, unit names are invalid as before.
pub(crate) fn parse_with_units(text: &str, units: Option<Units>) -> Option<f64> {
if text.len() > 1024 {
return None;
}
let normalized = text.replace('−', "-");
let mut parser = Parser { rest: &normalized };
let mut parser = Parser { rest: &normalized, units };
let value = parser.expression(0, 0)?;
parser.space();
(parser.rest.is_empty() && value.is_finite()).then_some(value)
@@ -14,6 +49,7 @@ pub(crate) fn parse(text: &str) -> Option<f64> {
struct Parser<'a> {
rest: &'a str,
units: Option<Units<'a>>,
}
impl Parser<'_> {
fn space(&mut self) {
@@ -103,8 +139,38 @@ impl Parser<'_> {
}
let value = self.rest.get(..length)?.parse::<f64>().ok()?;
self.rest = self.rest.get(length..)?;
let value = match self.units {
Some(units) => self.unit(value, units)?,
None => value,
};
value.is_finite().then_some(value)
}
/// The unit after a number, if any, converting `value` into the field's unit.
fn unit(&mut self, value: f64, units: Units) -> Option<f64> {
let rest = self.rest.trim_start();
let typed = UNIT_NAMES.iter().find(|(name, _)| rest.starts_with(name)).filter(|(name, _)| {
// `%` followed by an operand is the remainder operator (`10%3`), not percent.
*name != "%" || rest.get(1..).map(str::trim_start).and_then(|r| r.chars().next()).is_none_or(|c| matches!(c, ')' | '+' | '-' | '*' | '/' | '^'))
});
let Some(&(name, typed)) = typed else {
// The field's own suffix (`45°`), when it is no operator or number of its own.
let own = units.suffix;
let operand = |c: char| c.is_ascii_digit() || matches!(c, '.' | '(' | ')' | '+' | '-' | '*' | '/' | '%' | '^');
if !own.is_empty() && !own.starts_with(operand) && rest.starts_with(own) {
self.rest = rest.get(own.len()..)?;
}
return Some(value);
};
self.rest = rest.get(name.len()..)?;
let dpi = if units.dpi.is_finite() && units.dpi > 0.0 { units.dpi } else { 72.0 };
let ppi = if units.ppi.is_finite() && units.ppi > 0.0 { units.ppi } else { 72.0 };
match units.unit {
Some(field) if field == typed => Some(value),
// Lengths convert through pixels; a percentage has no length to convert against.
Some(field) if field != Unit::Percent && typed != Unit::Percent => Some(field.from_px(typed.to_px(value, dpi, 1.0, ppi), dpi, 1.0, ppi)),
_ => None,
}
}
}
#[cfg(test)]
@@ -137,6 +203,58 @@ mod tests {
assert_eq!(parse(&"(".repeat(1000)), None);
assert_eq!(parse(&"1".repeat(1025)), None);
}
#[test]
fn units_convert_into_the_field_unit_and_bad_input_is_rejected() {
use super::{Unit, Units, parse_with_units};
let px = Units { unit: Some(Unit::Pixels), suffix: "px", dpi: 300.0, ppi: 72.0 };
let pt = Units { unit: Some(Unit::Points), suffix: "pt", dpi: 144.0, ppi: 72.0 };
let pct = Units { unit: Some(Unit::Percent), suffix: "%", dpi: 72.0, ppi: 72.0 };
let deg = Units { unit: None, suffix: "°", dpi: 72.0, ppi: 72.0 };
for (text, units, want) in [
("1in", px, 300.0),
("2.54cm", px, 300.0),
("1in+10", px, 310.0),
("1 in * 2", px, 600.0),
("-25.4mm", px, -300.0),
("72pt", px, 300.0),
("1pica", px, 50.0),
("1pc", px, 50.0),
("100px", px, 100.0),
("(1in)/3", px, 100.0),
("144px", pt, 72.0),
("12", pt, 12.0),
("50%", pct, 50.0),
("50 % + 10", pct, 60.0),
("10%3", pct, 1.0),
("45°", deg, 45.0),
("45°*2", deg, 90.0),
] {
let got = parse_with_units(text, Some(units));
assert!(got.is_some_and(|v| (v - want).abs() < 1e-9), "{text}: {got:?}, want {want}");
}
let nan_dpi = Units { dpi: f64::NAN, ppi: 0.0, ..px };
assert_eq!(parse_with_units("1in", Some(nan_dpi)), Some(72.0), "a bad resolution falls back to 72");
for (text, units) in [
("", px),
("1/0", px),
("1e400", px),
("((((", px),
("你好", px),
("1in/0", px),
("50%", px),
("2in", pct),
("2in", deg),
("2 inches", px),
("2px3", px),
("in", px),
("1e308in", px),
("°", deg),
("2°", px),
] {
assert_eq!(parse_with_units(text, Some(units)), None, "{text}");
}
assert_eq!(parse("1in"), None, "without a field unit, unit names stay invalid");
}
}
#[cfg(test)]
+3 -1
View File
@@ -1490,10 +1490,12 @@ fn body_text_width(ui: &egui::Ui, s: &str) -> f32 {
}
/// Options-bar label: Photoshop writes "Size:" with a colon.
/// An options-bar label; one right before a value field scrubs it when dragged (Photoshop).
fn opt_label(ui: &mut egui::Ui, s: &str) {
let t = Tokens::get(ui.ctx());
let text = if t.pro && !s.ends_with(':') { format!("{s}:") } else { s.to_string() };
ui.label(RichText::new(tl!(&text)).color(t.text_dim));
let label = ui.label(RichText::new(tl!(&text)).color(t.text_dim));
widgets::scrub_label(ui, &label);
}
fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32) {
+3 -1
View File
@@ -146,7 +146,9 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map<String, Value>, key: &str, orig_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 };
// 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();
// A typed unit (`2in` while in pixels) converts at the dialog's resolution.
let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 };
let changed = crate::widgets::value_field_units(ui, &mut v, range, 90.0, crate::widgets::unit_decimals(&unit), units).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 }));
+3 -2
View File
@@ -1719,7 +1719,8 @@ fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) {
let t = crate::theme::Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover());
crate::icons::paint(ui, r, icon, 12.0, t.text_dim);
resp.on_hover_text(tip);
// Photoshop's Character panel icons scrub the field beside them.
crate::widgets::scrub_label(ui, &resp.on_hover_text(tip));
}
/// Labelled numeric field (Photoshop's icon + value pairs). Returns the new value when edited.
@@ -1731,7 +1732,7 @@ fn num_field(ui: &mut egui::Ui, label: &str, tip: &str, v: f32, range: std::ops:
ui.spacing_mut().item_spacing.x = 4.0;
let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover());
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, crate::theme::semibold(10.0), t.text_dim);
resp.on_hover_text(tip);
crate::widgets::scrub_label(ui, &resp.on_hover_text(tip));
if crate::widgets::value_field(ui, &mut v, range, unit, width).changed() {
out = Some(v);
}
+286 -24
View File
@@ -239,9 +239,11 @@ pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response {
resp
}
/// Monospace numeric field with a dimmed unit suffix, Photoshop style. Drag to scrub.
/// Monospace numeric field with a dimmed unit suffix, Photoshop style. Drag to scrub. Typed
/// arithmetic (`1920/2`) applies on Enter, Tab or click-away, and a unit after a number converts
/// into the field's unit (`1in` in a px field, at the resolution [`set_field_resolution`] gave).
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, None).0
value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts::default()).0
}
/// Height of a [`value_field`]'s box.
@@ -250,15 +252,13 @@ pub const VALUE_FIELD_H: f32 = 24.0;
/// A [`value_field`] `height` points tall instead of [`VALUE_FIELD_H`], for slim bars such as
/// the status bar (#2823).
pub fn compact_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, suffix: &str, width: f32, height: f32) -> Response {
value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, None).0
value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, FieldOpts::default()).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 {
/// A size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`) as a unit.
pub fn unit_of_key(key: &str) -> photocraft_engine::prefs::Unit {
use photocraft_engine::prefs::Unit;
let unit = match key {
match key {
"in" => Unit::Inches,
"cm" => Unit::Centimeters,
"mm" => Unit::Millimeters,
@@ -266,14 +266,163 @@ pub fn unit_decimals(key: &str) -> u32 {
"pica" => Unit::Picas,
"percent" => Unit::Percent,
_ => Unit::Pixels,
};
unit.decimals() as u32
}
}
/// 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 {
unit_of_key(key).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
value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts { decimals: Some(decimals), units: None }).0
}
/// A field's unit and the resolution a typed unit converts at (`1in` in a px field).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldUnits {
pub unit: photocraft_engine::prefs::Unit,
/// Pixels per inch.
pub dpi: f64,
/// Points per inch (72 PostScript, 72.27 traditional).
pub ppi: f64,
}
/// A [`value_field_prec`] without a suffix, whose unit is chosen beside it (Image Size and New
/// Document's unit menus) and converts typed units at that dialog's own resolution.
pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32>, width: f32, decimals: u32, units: FieldUnits) -> Response {
value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units) }).0
}
fn field_resolution_id() -> egui::Id {
egui::Id::new("pc-field-resolution")
}
/// The resolution (pixels per inch) and point size (points per inch) a unit typed into a value
/// field converts at: the active document's. The app sets it each frame; 72 / 72 without it.
pub fn set_field_resolution(ctx: &egui::Context, dpi: f64, ppi: f64) {
ctx.data_mut(|d| d.insert_temp(field_resolution_id(), (dpi, ppi)));
}
/// The unit a field's suffix names (`px`, `in`, `cm`, `mm`, `pt`, `pica`/`pc`, `%`), if any.
fn unit_of_suffix(suffix: &str) -> Option<photocraft_engine::prefs::Unit> {
use photocraft_engine::prefs::Unit;
Some(match suffix {
"px" => Unit::Pixels,
"in" => Unit::Inches,
"cm" => Unit::Centimeters,
"mm" => Unit::Millimeters,
"pt" => Unit::Points,
"pica" | "pc" => Unit::Picas,
"%" => Unit::Percent,
_ => return None,
})
}
/// A drag on a value field's label this frame, for the value field drawn next on the label's row
/// (Photoshop's scrubby labels). Left by [`scrub_label`], taken by that field.
#[derive(Clone, Copy, Debug)]
struct LabelScrub {
pass: u64,
label: egui::Id,
rect: Rect,
/// How far right of the label the field may start (the whole row for a slider row).
max_gap: f32,
/// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast).
steps: f32,
started: bool,
dragging: bool,
stopped: bool,
}
fn label_scrub_id() -> egui::Id {
egui::Id::new("pc-label-scrub")
}
/// Makes `label` scrubby: dragging it left or right moves the value field drawn right after it on
/// the same row (one step a point, ×10 with Shift, a tenth as fast with Alt), with Photoshop's
/// scrubby cursor. A label with no value field right after it stays a plain label.
pub fn scrub_label(ui: &Ui, label: &Response) -> Response {
scrub_label_within(ui, label, 24.0)
}
fn scrub_label_within(ui: &Ui, label: &Response, max_gap: f32) -> Response {
// Drag only, not focusable: Tab goes to the value field, not to its label as well.
let resp = label.interact(Sense::DRAG);
// Whether a field took this label's drags last frame: only then is it scrubby.
let linked = ui.data(|d| d.get_temp::<bool>(resp.id.with("pc-scrub-linked"))).unwrap_or(false);
if linked && resp.enabled() && (resp.hovered() || resp.dragged()) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
let acc_id = resp.id.with("pc-scrub-acc");
let mut steps = 0.0;
if resp.dragged() || resp.drag_stopped() {
let mut acc: f32 = if resp.drag_started() { 0.0 } else { ui.data(|d| d.get_temp(acc_id)).unwrap_or(0.0) };
let mods = ui.input(|i| i.modifiers);
let rate = if mods.shift {
10.0
} else if mods.alt {
0.1
} else {
1.0
};
// The drag starts once the pointer has moved a few points: count those too.
let dx = match (resp.drag_started(), ui.input(|i| i.pointer.press_origin()), resp.interact_pointer_pos()) {
(true, Some(origin), Some(pos)) => pos.x - origin.x,
_ => resp.drag_delta().x,
};
acc += dx * rate;
if !acc.is_finite() {
acc = 0.0;
}
steps = acc.trunc();
ui.data_mut(|d| d.insert_temp(acc_id, acc - steps));
}
let scrub = LabelScrub {
pass: ui.ctx().cumulative_pass_nr(),
label: resp.id,
rect: resp.rect,
max_gap,
steps,
started: resp.drag_started(),
dragging: resp.dragged(),
stopped: resp.drag_stopped(),
};
ui.data_mut(|d| {
d.insert_temp(label_scrub_id(), scrub);
d.insert_temp(resp.id.with("pc-scrub-linked"), false);
});
resp
}
/// The label drag left for the field at `rect` this frame, if its label is just left of it on
/// its row. Taken either way: a label only scrubs the next field drawn.
fn take_label_scrub(ui: &Ui, rect: Rect) -> Option<LabelScrub> {
let s = ui.data_mut(|d| {
let s = d.get_temp::<LabelScrub>(label_scrub_id());
d.remove::<LabelScrub>(label_scrub_id());
s
})?;
let same_row = rect.top() < s.rect.bottom() && rect.bottom() > s.rect.top();
let gap = rect.left() - s.rect.right();
let linked = s.pass == ui.ctx().cumulative_pass_nr() && same_row && gap >= -1.0 && gap <= s.max_gap;
if linked {
ui.data_mut(|d| d.insert_temp(s.label.with("pc-scrub-linked"), true));
}
linked.then_some(s)
}
/// How a [`value_field_in`] shows and reads its number, beyond its suffix.
#[derive(Clone, Copy, Debug, Default)]
struct FieldOpts {
/// Decimal places shown (default: one, or two for a small range).
decimals: Option<u32>,
/// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]).
units: Option<FieldUnits>,
}
/// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and
@@ -285,9 +434,9 @@ fn value_field_in(
suffix: &str,
width: f32,
trailing: f32,
decimals: Option<u32>,
opts: FieldOpts,
) -> (Response, Rect) {
value_field_box(ui, value, range, suffix, vec2(width, VALUE_FIELD_H), trailing, decimals)
value_field_box(ui, value, range, suffix, vec2(width, VALUE_FIELD_H), trailing, opts)
}
/// [`value_field_in`] with a box of any `size`.
@@ -298,10 +447,19 @@ fn value_field_box(
suffix: &str,
size: Vec2,
trailing: f32,
decimals: Option<u32>,
opts: FieldOpts,
) -> (Response, Rect) {
let FieldOpts { decimals, units } = opts;
let t = Tokens::get(ui.ctx());
let (rect, slot) = ui.allocate_exact_size(size, Sense::hover());
let scrub = take_label_scrub(ui, rect);
let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0));
// Typed text loses its spaces before parsing, so a " pt" suffix reads as "pt".
let typed_suffix = suffix.trim();
let units = match units {
Some(u) => crate::numeric_expression::Units { unit: Some(u.unit), suffix: typed_suffix, dpi: u.dpi, ppi: u.ppi },
None => crate::numeric_expression::Units { unit: unit_of_suffix(typed_suffix), suffix: typed_suffix, dpi, ppi },
};
surface(ui, rect, t.field, false);
if !t.bevel {
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
@@ -339,12 +497,34 @@ fn value_field_box(
ui.spacing_mut().button_padding = Vec2::ZERO;
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, decimals)).inner
ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units)).inner
})
.inner
}
};
ui.data_mut(|d| d.insert_temp(slot.id, resp.id));
if let Some(s) = scrub {
if s.started && ui.memory(|m| m.has_focus(resp.id)) {
ui.memory_mut(|m| m.surrender_focus(resp.id));
}
if s.steps != 0.0 {
let unit = if fine { 0.01 } else { 1.0 };
// Onto the step grid first, so a whole-number field drops its decimals (55.4 + 1 = 56).
let v = ((*value / unit).round() + s.steps) * unit;
let v = (v * 1e4).round() / 1e4;
// `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts.
let v = if lo <= hi { v.clamp(lo, hi) } else { v };
if v.is_finite() && v != *value {
*value = v;
resp.mark_changed();
}
}
// Callers coalesce a drag into one history step; the label's drag is the field's.
use egui::response::Flags;
resp.flags.set(Flags::DRAG_STARTED, resp.flags.contains(Flags::DRAG_STARTED) || s.started);
resp.flags.set(Flags::DRAGGED, resp.flags.contains(Flags::DRAGGED) || s.dragging);
resp.flags.set(Flags::DRAG_STOPPED, resp.flags.contains(Flags::DRAG_STOPPED) || s.stopped);
}
if !suffix.is_empty() {
ui.painter().text(pos2(rect.right() - 6.0 - trailing, rect.center().y), Align2::RIGHT_CENTER, suffix, suffix_font, t.text_faint);
}
@@ -388,7 +568,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, None);
let (field, rect) = value_field_in(ui, value, range.clone(), suffix, width, POPUP_ARROW_W, FieldOpts::default());
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();
@@ -479,7 +659,14 @@ 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, decimals: Option<u32>) -> Response {
fn number_edit(
ui: &mut Ui,
value: &mut f32,
range: std::ops::RangeInclusive<f32>,
fine: bool,
decimals: Option<u32>,
units: crate::numeric_expression::Units,
) -> 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) {
@@ -505,7 +692,7 @@ fn number_edit(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive<f32
.update_while_editing(!math)
// Focus is read when parsing, not above: Tab hands it on inside `ui.add`.
.custom_parser(move |s| {
let v = parse_num(s);
let v = parse_num_in(s, units);
if ctx.memory(|m| m.has_focus(id)) && plain(s).is_none() {
// Still typing arithmetic: hold it, and stop applying keystrokes once it parses.
ctx.data_mut(|d| d.insert_temp(held, v.is_some()));
@@ -730,17 +917,22 @@ pub fn slider_row_with(
let t = Tokens::get(ui.ctx());
let mut changed_resp = None;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
// The label scrubs the row's field, across the row (Photoshop's scrubby labels).
let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim));
scrub_label_within(ui, &l, f32::INFINITY);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
changed_resp = Some(value_field(ui, value, range.clone(), suffix, 74.0));
});
});
let s = slider_with(ui, value, range, gradient, g);
let mut r = s.clone();
if let Some(v) = changed_resp
&& v.changed()
{
r.mark_changed();
if let Some(v) = changed_resp {
if v.changed() {
r.mark_changed();
}
// A scrub of the field or its label is one drag, like the slider's, for history.
use egui::response::Flags;
r.flags |= v.flags & (Flags::DRAG_STARTED | Flags::DRAGGED | Flags::DRAG_STOPPED);
}
ui.add_space(4.0);
r
@@ -1354,6 +1546,15 @@ pub fn parse_num(text: &str) -> Option<f64> {
crate::numeric_expression::parse(&clean(text))
}
/// [`parse_num`] in a field with `units`: a number may carry a unit (`2in`, `5mm`, `50%`),
/// converted into the field's unit; one that can't convert makes the text invalid.
pub(crate) fn parse_num_in(text: &str, units: crate::numeric_expression::Units) -> Option<f64> {
if text.len() > 1024 {
return None;
}
crate::numeric_expression::parse_with_units(&clean(text), Some(units))
}
/// A plain typed number, no arithmetic.
fn plain(text: &str) -> Option<f64> {
clean(text).parse().ok()
@@ -1449,7 +1650,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, None).1;
s.1 = super::value_field_in(ui, &mut s.0, 0.0..=100.0, "px", 80.0, 0.0, super::FieldOpts::default()).1;
},
(50.0, egui::Rect::NOTHING),
);
@@ -1567,6 +1768,67 @@ mod tests {
assert_eq!(h.state().1, 0);
}
/// Photoshop: a unit typed into a px field converts at the document resolution on Enter, and
/// an expression that can't convert (or divides by zero) keeps the value.
#[test]
fn enter_commits_a_typed_unit_expression_at_the_document_resolution() {
let mut h = field(100.0, 0.0..=10_000.0);
super::set_field_resolution(&h.ctx, 300.0, 72.0);
for (text, want) in [("1in+10", 310.0), ("2.54 cm * 2", 600.0), ("50%", 600.0), ("1in/0", 600.0), ("10px", 10.0)] {
h.get_by_role(egui::accesskit::Role::SpinButton).click();
h.run();
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(egui::Event::Text(text.into()));
h.run();
h.key_press(Key::Enter);
h.run();
assert_eq!(h.state().0, want, "{text}");
}
}
/// Photoshop's scrubby labels: dragging the label right before a field moves its value, a
/// step a point (Shift ×10); a label with no field right after it moves nothing.
#[test]
fn dragging_a_label_scrubs_the_field_right_after_it() {
let mut h = Harness::new_ui_state(
|ui, s: &mut (f32, f32)| {
ui.horizontal(|ui| {
let l = ui.label("Size:");
super::scrub_label(ui, &l);
super::value_field(ui, &mut s.0, 0.0..=1000.0, "px", 80.0);
});
ui.horizontal(|ui| {
let l = ui.label("Mode:");
super::scrub_label(ui, &l);
ui.add_space(60.0);
super::value_field(ui, &mut s.1, 0.0..=1000.0, "px", 80.0);
});
},
(100.0, 100.0),
);
h.run();
let drag = |h: &mut Harness<'static, (f32, f32)>, label: &str, dx: f32, mods: Modifiers| {
let at = h.get_by_label(label).rect().center();
h.hover_at(at);
h.run();
h.drag_at(at);
h.run();
for i in 1..=4 {
h.event(egui::Event::PointerMoved(at + egui::vec2(dx * i as f32 / 4.0, 0.0)));
h.event(egui::Event::ModifiersChanged(mods));
h.run();
}
h.drop_at(at + egui::vec2(dx, 0.0));
h.run();
};
drag(&mut h, "Size:", 20.0, Modifiers::NONE);
assert_eq!(h.state().0, 120.0);
drag(&mut h, "Size:", -3.0, Modifiers::SHIFT);
assert_eq!(h.state().0, 90.0);
drag(&mut h, "Mode:", 20.0, Modifiers::NONE);
assert_eq!(h.state().1, 100.0, "the field isn't right after the label");
}
/// A right-aligned OK / Cancel / Apply row as `os` draws it: labels left to right, and the
/// row's right edge with the window's.
fn button_row(os: egui::os::OperatingSystem) -> (Vec<String>, f32, f32) {
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 26 | 15 | 2 | |
| [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 30 of 154 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 10 | 7 | 6 | settings that do nothing: 30 of 154 (target 0) |
| [Type](#type) | 4 | 2 | 3 | |
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -194,7 +194,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No such prefs; start screen (canvas.rs:825) has no recents |
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes |
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | partial | [#217](https://github.com/storytold/photocraft/issues/217) | widgets.rs:132 DragValue scrubs and takes + - * / arithmetic; no units in expressions, label scrub or reset |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | partial | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); trash is click-only, no drop on New/Trash, no checker behind art |
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_dialog.rs adds black/gray/white eyedroppers; smoothing and pencil modes remain missing |
| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue |
+2 -2
View File
@@ -92,9 +92,9 @@ note = "document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/del
[[item]]
id = "UI-217-13"
target = "Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider"
status = "partial"
status = "done"
issue = 217
note = "widgets.rs:132 DragValue scrubs and takes + - * / arithmetic; no units in expressions, label scrub or reset"
note = "widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click"
[[item]]
id = "UI-217-14"