mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* 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:
11 files changed
+433
-37
No files matched your search
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user