diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 598956aa..49126d59 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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 { - 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 { 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::().ok()?), + '+' => Some(parse_measure(l, default)? + parse_measure(r, default)?), + '-' => Some(parse_measure(l, default)? - parse_measure(r, default)?), _ => { - let d = r.trim().parse::().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 { { return Some(p.trim().parse::().ok()? * 12.0 + pt.trim().parse::().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)> { + 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 { + 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)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bda31d7b..3e168386 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs new file mode 100644 index 00000000..716f3517 --- /dev/null +++ b/crates/ui-egui/src/tests_numfields.rs @@ -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, + id: Cell, +} + +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, draw: &dyn Fn(&mut egui::Ui) -> Option) -> Option { + 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) -> Option { + self.frame(events, &|ui| widgets::num_field(ui, "f", Some(100.0), Unit::Pixels, 80.0)) + } + + fn click(&self, count: usize) -> Vec { + 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)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4d8bcc12..3b2512ba 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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::().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();