Fix #33: position and size fields evaluate math typed before the unit suffix
A `*` or `/` operand had to be a bare number, so `1080/2 px` (the number replaced by an expression, the field's suffix still there) read as nothing. The unit after a factor or divisor now measures the whole expression, in every unit (`Unit::parse`, shared by every unit field). Numeric fields (num_field, opt_field, plain/mixed fields) select their whole text, unit included, when they gain focus or are double-clicked, so typing replaces it; unitless fields gain the same arithmetic (`parse_number`, `45*2°`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1499834de7
commit
6780ffae60
|
|
@ -177,8 +177,11 @@ impl Unit {
|
|||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
|
||||
///
|
||||
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
|
||||
/// math typed before a field's unit suffix works.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self)
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
|
||||
/// `feetInches`).
|
||||
|
|
@ -221,14 +224,20 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
|||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let a = parse_measure(l, default)?;
|
||||
return match op {
|
||||
'+' => Some(a + parse_measure(r, default)?),
|
||||
'-' => Some(a - parse_measure(r, default)?),
|
||||
'*' => Some(a * r.trim().parse::<f64>().ok()?),
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
_ => {
|
||||
let d = r.trim().parse::<f64>().ok()?;
|
||||
if d == 0.0 { None } else { Some(a / d) }
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
} else if k == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(a / k)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -242,22 +251,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
|||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
}
|
||||
|
||||
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
|
||||
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
let s = s.trim();
|
||||
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let v: f64 = s[..num_end].trim().parse().ok()?;
|
||||
let unit = match s[num_end..].trim() {
|
||||
"" => default,
|
||||
"pt" => Unit::Points,
|
||||
"px" => Unit::Pixels,
|
||||
"in" | "\"" => Unit::Inches,
|
||||
"mm" => Unit::Millimeters,
|
||||
"cm" => Unit::Centimeters,
|
||||
"m" => Unit::Meters,
|
||||
"ft" | "'" => Unit::Feet,
|
||||
"yd" => Unit::Yards,
|
||||
"pc" => Unit::Picas,
|
||||
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
|
||||
let unit = match s.get(num_end..)?.trim() {
|
||||
"" => None,
|
||||
"pt" => Some(Unit::Points),
|
||||
"px" => Some(Unit::Pixels),
|
||||
"in" | "\"" => Some(Unit::Inches),
|
||||
"mm" => Some(Unit::Millimeters),
|
||||
"cm" => Some(Unit::Centimeters),
|
||||
"m" => Some(Unit::Meters),
|
||||
"ft" | "'" => Some(Unit::Feet),
|
||||
"yd" => Some(Unit::Yards),
|
||||
"p" | "pc" => Some(Unit::Picas),
|
||||
_ => return None,
|
||||
};
|
||||
Some(unit.to_pt(v))
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
|
||||
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
|
||||
pub fn parse_number(s: &str) -> Option<f64> {
|
||||
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
// A leading `+` is a sign (`+5`), as a plain number read it.
|
||||
let s = s.trim();
|
||||
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ mod tests_labels;
|
|||
#[cfg(test)]
|
||||
mod tests_nativeoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_numfields;
|
||||
#[cfg(test)]
|
||||
mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
//! Numeric fields: math typed before the unit suffix, and the whole text (unit included) selected
|
||||
//! on focus or double-click so typing replaces it.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
|
||||
use vectorcraft_doc::Unit;
|
||||
|
||||
use crate::widgets;
|
||||
|
||||
/// Headless frames of one field: its rect and id as last drawn.
|
||||
struct Field {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
rect: Cell<Rect>,
|
||||
id: Cell<egui::Id>,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL) }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>, draw: &dyn Fn(&mut egui::Ui) -> Option<f64>) -> Option<f64> {
|
||||
self.time += 0.05;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
|
||||
let input = egui::RawInput { events, time: Some(self.time), screen_rect, ..Default::default() };
|
||||
let mut got = None;
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
self.id.set(ui.id().with("f"));
|
||||
got = draw(ui);
|
||||
self.rect.set(ui.min_rect());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
got
|
||||
}
|
||||
|
||||
fn num(&mut self, events: Vec<Event>) -> Option<f64> {
|
||||
self.frame(events, &|ui| widgets::num_field(ui, "f", Some(100.0), Unit::Pixels, 80.0))
|
||||
}
|
||||
|
||||
fn click(&self, count: usize) -> Vec<Event> {
|
||||
let at = self.rect.get().center();
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
std::iter::once(Event::PointerMoved(at)).chain((0..count).flat_map(|_| [button(true), button(false)])).collect()
|
||||
}
|
||||
|
||||
/// The selected character range of the field's text.
|
||||
fn selection(&self) -> Option<(usize, usize)> {
|
||||
let st = egui::TextEdit::load_state(&self.ctx, self.id.get())?;
|
||||
let r = st.cursor.char_range()?.as_sorted_char_range();
|
||||
Some((r.start.0, r.end.0))
|
||||
}
|
||||
}
|
||||
|
||||
fn enter() -> Event {
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_before_the_unit_suffix_evaluates_in_every_unit() {
|
||||
assert_eq!(Unit::Pixels.parse("1080/2 px"), Some(540.0));
|
||||
assert_eq!(Unit::Points.parse("1080/2 px"), Some(540.0));
|
||||
assert_eq!(Unit::Pixels.parse("100*3 px"), Some(300.0));
|
||||
for u in Unit::ALL {
|
||||
let shown = u.format(u.to_pt(12.0));
|
||||
// "12 mm" with "*2" typed after the number, before the suffix.
|
||||
let typed = shown.replacen(&u.number(u.to_pt(12.0)), "12*2", 1);
|
||||
let v = u.parse(&typed).unwrap_or_else(|| panic!("{u:?}: `{typed}`"));
|
||||
assert!((v - u.to_pt(24.0)).abs() < 1e-9, "{u:?}: `{typed}` → {v}");
|
||||
let typed = shown.replacen(&u.number(u.to_pt(12.0)), "12/4", 1);
|
||||
let v = u.parse(&typed).unwrap_or_else(|| panic!("{u:?}: `{typed}`"));
|
||||
assert!((v - u.to_pt(3.0)).abs() < 1e-9, "{u:?}: `{typed}` → {v}");
|
||||
}
|
||||
// The suffix measures the expression; units given in it still win.
|
||||
assert_eq!(Unit::Points.parse("1in/2 mm"), Some(36.0));
|
||||
assert_eq!(Unit::Points.parse("10/0 px"), None);
|
||||
// Unitless fields.
|
||||
assert_eq!(vectorcraft_doc::parse_number("45*2"), Some(90.0));
|
||||
assert_eq!(vectorcraft_doc::parse_number(" 100 / 4 "), Some(25.0));
|
||||
assert_eq!(vectorcraft_doc::parse_number("+5"), Some(5.0));
|
||||
assert_eq!(vectorcraft_doc::parse_number("-5"), Some(-5.0));
|
||||
assert_eq!(vectorcraft_doc::parse_number("3 mm"), None);
|
||||
assert_eq!(vectorcraft_doc::parse_number("inf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_selects_the_whole_field_so_typing_replaces_it() {
|
||||
let mut f = Field::new();
|
||||
f.num(vec![]);
|
||||
f.num(f.click(1));
|
||||
f.num(vec![]);
|
||||
assert_eq!(f.selection(), Some((0, "100 px".len())), "the unit is selected too");
|
||||
f.num(vec![Event::Text("1080/2".into())]);
|
||||
assert_eq!(f.num(vec![enter()]), Some(540.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_selects_the_whole_field() {
|
||||
let mut f = Field::new();
|
||||
f.num(vec![]);
|
||||
f.num(f.click(1));
|
||||
f.num(vec![]);
|
||||
// A click in the focused field (not part of a double-click) places the caret; a double-click
|
||||
// takes everything again.
|
||||
f.time += 1.0;
|
||||
f.num(f.click(1));
|
||||
f.num(vec![]);
|
||||
assert!(f.selection().is_some_and(|(a, b)| a == b), "{:?}", f.selection());
|
||||
f.num(f.click(2));
|
||||
f.num(vec![]);
|
||||
assert_eq!(f.selection(), Some((0, "100 px".len())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_fields_do_math_with_their_suffix() {
|
||||
let mut f = Field::new();
|
||||
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 10.0, "°", 2, 80.0);
|
||||
f.frame(vec![], &draw);
|
||||
f.frame(f.click(1), &draw);
|
||||
f.frame(vec![], &draw);
|
||||
assert_eq!(f.selection(), Some((0, "10°".chars().count())));
|
||||
f.frame(vec![Event::Text("45*2°".into())], &draw);
|
||||
assert_eq!(f.frame(vec![enter()], &draw), Some(90.0));
|
||||
}
|
||||
|
|
@ -125,11 +125,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
|||
.inner
|
||||
})
|
||||
.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
let commit = resp.lost_focus() && buf != shown;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if commit { unit.parse(&buf) } else { None }
|
||||
}
|
||||
|
||||
/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked
|
||||
/// (egui would select one word): typing a number or an expression replaces it.
|
||||
fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
|
||||
if !(resp.gained_focus() || resp.double_clicked()) {
|
||||
return;
|
||||
}
|
||||
if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), resp.id) {
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
|
||||
st.cursor.set_char_range(Some(all));
|
||||
st.store(ui.ctx(), resp.id);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line),
|
||||
/// its text kept under `id` while it has focus. Returns the text (as edited) and the response.
|
||||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
|
||||
|
|
@ -206,8 +221,12 @@ pub fn mixed_field(
|
|||
})
|
||||
.unwrap_or_default();
|
||||
let (buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
if resp.lost_focus() && buf != shown {
|
||||
buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::<f64>().ok()
|
||||
// The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`.
|
||||
let suffix = suffix.trim();
|
||||
let bare = if suffix.is_empty() { buf.clone() } else { buf.replace(suffix, "") };
|
||||
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
@ -1001,6 +1020,7 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
|||
)
|
||||
})
|
||||
.inner;
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus() && buf != shown {
|
||||
let s = buf.trim();
|
||||
|
|
|
|||
Loading…
Reference in New Issue