mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 15:53:01 +00:00
The Up/Down stepping of #357 now serves every numeric field through one helper: num_field (lengths, in their unit) and plain/mixed fields (degrees, percent, counts, at their precision: a count ignores the Ctrl/Cmd tenth), as in Illustrator's panels and dialogs. After a step the new value is selected, so typing replaces it. The select-all code the dialog focus and focus/double-click already duplicated becomes one helper, and the tests move to tests_numfields.rs with its field harness (which can now run with the window unfocused). ROADMAP.md notes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
7.3 KiB
Rust
183 lines
7.3 KiB
Rust
//! 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>,
|
|
/// Whether the window reports keyboard focus (the live app's doesn't always).
|
|
focused: bool,
|
|
}
|
|
|
|
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), focused: true }
|
|
}
|
|
|
|
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, focused: self.focused, ..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 {
|
|
key(Key::Enter, Modifiers::NONE)
|
|
}
|
|
|
|
fn key(key: Key, modifiers: Modifiers) -> Event {
|
|
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
/// ↑/↓ step a focused numeric field by one of its unit (Shift: ten, Ctrl/Cmd: a tenth) and apply it
|
|
/// at once, the new value selected so typing replaces it; an unfocused field leaves the arrows to
|
|
/// the canvas (nudge).
|
|
#[test]
|
|
fn arrow_keys_step_a_focused_field() {
|
|
let mut f = Field::new();
|
|
let u = Unit::Millimeters;
|
|
let mm = Cell::new(4.0);
|
|
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v)));
|
|
f.frame(vec![], &draw);
|
|
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &draw), None, "unfocused");
|
|
f.frame(f.click(1), &draw);
|
|
f.frame(vec![], &draw);
|
|
// The window needn't report keyboard focus: the field has egui's.
|
|
f.focused = false;
|
|
for (k, mods, want) in [
|
|
(Key::ArrowUp, Modifiers::NONE, 5.0),
|
|
(Key::ArrowUp, Modifiers::SHIFT, 15.0),
|
|
(Key::ArrowDown, Modifiers::COMMAND, 14.9),
|
|
(Key::ArrowDown, Modifiers::NONE, 13.9),
|
|
] {
|
|
assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}");
|
|
assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get());
|
|
}
|
|
assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected");
|
|
// Enter after the steps commits nothing more.
|
|
assert_eq!(f.frame(vec![enter()], &draw), None);
|
|
}
|
|
|
|
/// Plain fields (degrees, percent, counts) step too, at their precision.
|
|
#[test]
|
|
fn arrow_keys_step_plain_fields() {
|
|
let mut f = Field::new();
|
|
let angle = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 10.0, "°", 2, 80.0);
|
|
f.frame(vec![], &angle);
|
|
f.frame(f.click(1), &angle);
|
|
f.frame(vec![], &angle);
|
|
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &angle), Some(11.0));
|
|
assert_eq!(f.frame(vec![key(Key::ArrowDown, Modifiers::SHIFT)], &angle), Some(1.0));
|
|
// A count ignores the tenth.
|
|
let mut f = Field::new();
|
|
let count = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 3.0, "", 0, 80.0);
|
|
f.frame(vec![], &count);
|
|
f.frame(f.click(1), &count);
|
|
f.frame(vec![], &count);
|
|
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::COMMAND)], &count), None);
|
|
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &count), Some(4.0));
|
|
}
|