mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
The option tells points from picas, where a bare `12` could be either: the reference app dims it unless a unit is Picas, and it starts on. A number typed with no unit into a field in picas is now read in points with it on (the default again, as before #486), and in picas with it off; fields in any other unit read it in their unit either way, as there. The Preferences dialog dims the checkbox unless one of the Units is Picas. The fields learn the preference when it changes, through `prefs_dialog::apply_runtime` as Scrub Numeric Fields does, rather than from a write into the egui context every frame. Tests for picas and other fields, the wiring and the dimming; docs/mcp.md and ROADMAP say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
13 KiB
Rust
295 lines
13 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));
|
||
}
|
||
|
||
/// Preferences › Units › Numbers Without Units Are Points (#394): on (the default), a number typed
|
||
/// with no unit into a field in picas is read in points; a typed unit still wins (`2p6`, `1in`);
|
||
/// off, it is in picas. A field in any other unit reads it in its unit either way.
|
||
#[test]
|
||
fn bare_numbers_in_picas_fields_are_points_when_the_preference_says_so() {
|
||
let mut f = Field::new();
|
||
let typed = |f: &mut Field, u: Unit, text: &str| {
|
||
let pt = Cell::new(u.to_pt(4.0));
|
||
let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), u, 80.0).inspect(|&v| pt.set(v));
|
||
f.frame(vec![], &draw);
|
||
f.frame(f.click(1), &draw);
|
||
f.frame(vec![Event::Text(text.into())], &draw);
|
||
f.frame(vec![enter()], &draw)
|
||
};
|
||
let (pc, mm) = (Unit::Picas, Unit::Millimeters);
|
||
assert_eq!(typed(&mut f, pc, "12"), Some(12.0), "on by default: points in a picas field");
|
||
assert_eq!(typed(&mut f, pc, "2p6"), Some(30.0), "picas and points as typed");
|
||
assert_eq!(typed(&mut f, pc, "1in"), Some(72.0), "a typed unit wins");
|
||
assert_eq!(typed(&mut f, pc, "10+5"), Some(15.0), "arithmetic in points too");
|
||
assert_eq!(typed(&mut f, mm, "12"), Some(mm.to_pt(12.0)), "a millimetre field keeps millimetres");
|
||
widgets::set_bare_numbers_are_points(&f.ctx, false);
|
||
assert_eq!(typed(&mut f, pc, "12"), Some(pc.to_pt(12.0)), "off: picas");
|
||
assert_eq!(typed(&mut f, mm, "12"), Some(mm.to_pt(12.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));
|
||
}
|
||
|
||
/// A wheel turn of `dy` (`unit`s, up positive) with `modifiers`, the pointer at `at`.
|
||
fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Vec<Event> {
|
||
vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }]
|
||
}
|
||
|
||
/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift
|
||
/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer
|
||
/// elsewhere, the wheel isn't the field's.
|
||
#[test]
|
||
fn the_wheel_steps_a_focused_field_under_the_pointer() {
|
||
use egui::MouseWheelUnit::{Line, Point};
|
||
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);
|
||
let at = f.rect.get().center();
|
||
assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused");
|
||
f.frame(f.click(1), &draw);
|
||
f.frame(vec![], &draw);
|
||
for (dy, mods, want) in
|
||
[(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)]
|
||
{
|
||
assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}");
|
||
assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get());
|
||
}
|
||
assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected");
|
||
// Points: a step for each line's worth, what is left carried to the next turn.
|
||
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
|
||
assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None);
|
||
assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some());
|
||
assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get());
|
||
assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere");
|
||
// Plain fields (degrees, percent, counts) too.
|
||
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(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0));
|
||
}
|
||
|
||
/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one
|
||
/// it scrolls the panel as before.
|
||
#[test]
|
||
fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() {
|
||
for focus in [false, true] {
|
||
let mut f = Field::new();
|
||
let offset = Cell::new(0.0);
|
||
let draw = |ui: &mut egui::Ui| {
|
||
let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| {
|
||
let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0);
|
||
ui.add_space(600.0);
|
||
v
|
||
});
|
||
offset.set(out.state.offset.y);
|
||
out.inner
|
||
};
|
||
f.frame(vec![], &draw);
|
||
let at = Pos2::new(20.0, 12.0);
|
||
if focus {
|
||
f.frame(
|
||
f.click(1)
|
||
.into_iter()
|
||
.map(|e| {
|
||
if let Event::PointerButton { button, pressed, modifiers, .. } = e {
|
||
Event::PointerButton { pos: at, button, pressed, modifiers }
|
||
} else {
|
||
Event::PointerMoved(at)
|
||
}
|
||
})
|
||
.collect(),
|
||
&draw,
|
||
);
|
||
}
|
||
let stepped = f.frame(wheel(at, egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw);
|
||
for _ in 0..20 {
|
||
f.frame(vec![], &draw);
|
||
}
|
||
if focus {
|
||
assert_eq!((stepped, offset.get()), (Some(8.0), 0.0));
|
||
} else {
|
||
assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get());
|
||
}
|
||
}
|
||
}
|