Files
vectorcraft/crates/ui-egui/src/tests_numfields.rs
T
dotCooCoo 7e9d0be723 Fields: unit fields take % in arithmetic, as in 3cm*50% and 50pt+25%
Unit fields and length strings read a percentage operand as a share of
a value. After * or /, the percentage scales the term before it:
3cm*50% is 1.5 cm and 3cm/50% is 6 cm. After + or -, it adds or takes
away that share of everything before it: 50pt+25% is 62.5 pt and
100pt+20pt-50% is 60 pt. Before this change these expressions read as
nothing, and the field kept its old value.

The parser now splits a sum at its last + or -, so a percentage applies
to the whole sum before it. Text without % gives the same values as
before, apart from float rounding. A percentage on its own (50%) still
reads as nothing, and percent and degree fields still read % as their
suffix.

Command length strings use the same parser: file.new's width and
height, the distance params and the length preferences.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-16 06:16:35 +07:00

656 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Numeric fields: math typed before the unit suffix, and the whole text (unit included) selected
//! on keyboard focus, a released focus click or double-click so typing replaces it; a drag
//! keeps the pressed character as its selection anchor.
use std::cell::Cell;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2};
use vectorcraft_doc::Unit;
use crate::widgets;
/// #1457: a focused field follows its value when that changes from outside (the slider beside
/// it), so the arrow keys step from the value shown and losing focus doesn't put the old one back.
#[test]
fn a_focused_field_follows_a_value_changed_from_outside() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut frame = |value: f64, events: Vec<Event>| {
time += 0.05;
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
let mut got = (None, Rect::NOTHING);
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect, focused: true, ..Default::default() }, |ui| {
got.0 = widgets::range_field(ui, "f", value, 0.0..=1000.0, "%", 2, 80.0);
got.1 = ui.min_rect();
});
out.textures_delta.clear();
got
};
let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
// Click into the field showing 100 %.
let at = frame(100.0, vec![]).1.center();
frame(100.0, vec![Event::PointerMoved(at), button(at, true)]);
frame(100.0, vec![button(at, false)]);
assert_eq!(frame(100.0, vec![]).0, None);
// The value changes under the focused field (a slider dragged to 142): ↓ steps from 142, not
// from the 100 the field held when it took the focus.
frame(142.0, vec![]);
assert_eq!(frame(142.0, vec![key(Key::ArrowDown)]).0, Some(141.0));
// And changed again, losing focus leaves the value alone.
frame(50.0, vec![]);
let away = at + vec2(0.0, 150.0);
frame(50.0, vec![Event::PointerMoved(away), button(away, true)]);
assert_eq!(frame(50.0, vec![button(away, false)]).0, None);
}
/// #1692: a hex field that the focus only passes through shows the color as it is now and commits
/// nothing. Tab out of an edited field gives the hex field after it the focus in the frame the edit
/// is committed, and the color changes after the hex field is drawn, as in the Color Picker.
/// Shift+Tab out of the field after it gives it the focus a frame later, in the frame that field
/// commits.
#[test]
fn a_hex_field_passed_through_with_tab_commits_nothing() {
let mut f = Field::new();
// Red and Blue fields on either side of the hex field of the color they make.
let rgb = Cell::new([255.0, 128.0, 0.0]);
let (hex, blue, hex_id) = (Cell::new(None), Cell::new(None), Cell::new(egui::Id::NULL));
let draw = |ui: &mut egui::Ui| {
let [r, g, b] = rgb.get();
let red = widgets::plain_field(ui, "red", r, "", 0, 80.0);
hex_id.set(ui.id().with("hex"));
hex.set(widgets::hex_field(ui, "hex", &format!("{:02X}{:02X}{:02X}", r as u8, g as u8, b as u8)));
blue.set(widgets::plain_field(ui, "blue", b, "", 0, 80.0));
red
};
// One frame, its edits applied after it. → What the hex field committed.
let step = |f: &mut Field, events: Vec<Event>| {
let red = f.frame(events, &draw);
let [r, g, b] = rgb.get();
rgb.set([red.unwrap_or(r), g, blue.take().unwrap_or(b)]);
hex.take()
};
// The text the hex field shows (it keeps it under its id).
let shows = |f: &Field| f.ctx.data(|d| d.get_temp::<String>(hex_id.get()));
let (tab, back) = (key(Key::Tab, Modifiers::NONE), key(Key::Tab, Modifiers::SHIFT));
step(&mut f, vec![]);
// Tab into Red, type 50 over it, and Tab on to the hex field, then on to Blue.
for events in [vec![tab.clone()], vec![Event::Text("50".into())], vec![tab.clone()], vec![]] {
assert_eq!(step(&mut f, events), None);
}
assert_eq!(shows(&f).as_deref(), Some("328000"), "the hex field shows Red's edit");
for events in [vec![tab.clone()], vec![]] {
assert_eq!(step(&mut f, events), None);
}
assert_eq!(rgb.get(), [50.0, 128.0, 0.0], "Red keeps what was typed");
// Type 200 over Blue, Shift+Tab back into the hex field and Tab out of it again.
for events in [vec![Event::Text("200".into())], vec![back], vec![], vec![]] {
assert_eq!(step(&mut f, events), None);
}
assert_eq!(shows(&f).as_deref(), Some("3280C8"), "the hex field shows Blue's edit");
for events in [vec![tab], vec![]] {
assert_eq!(step(&mut f, events), None);
}
assert_eq!(rgb.get(), [50.0, 128.0, 200.0], "Blue keeps what was typed");
}
/// What is typed into a hex field is committed when Tab or Enter ends its focus, and Escape drops
/// it.
#[test]
fn typing_into_a_hex_field_commits_it() {
let mut f = Field::new();
let hex = Cell::new(None);
let draw = |ui: &mut egui::Ui| {
let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0);
hex.set(widgets::hex_field(ui, "hex", "FF8000"));
red
};
let step = |f: &mut Field, events: Vec<Event>| {
f.frame(events, &draw);
hex.take()
};
let tab = || key(Key::Tab, Modifiers::NONE);
let escape = || key(Key::Escape, Modifiers::NONE);
step(&mut f, vec![]);
for (end, commits) in [(tab(), true), (enter(), true), (escape(), false)] {
// Tab into Red and on into the hex field, select its text and type over it.
let mut got = vec![];
for events in
[vec![tab()], vec![tab()], vec![], vec![key(Key::A, Modifiers::COMMAND)], vec![Event::Text("00FF00".into())], vec![end.clone()], vec![]]
{
got.extend(step(&mut f, events));
}
let want: Vec<String> = if commits { vec!["00FF00".into()] } else { vec![] };
assert_eq!(got, want, "{end:?}");
// Neither field has the focus for the next round.
step(&mut f, vec![escape()]);
}
}
/// #1692: the Stroke panel's dash and gap fields ([`widgets::opt_field`]) are blank while the
/// selected objects' dashes differ. Tab out of the dash after typing into it gives the gap the
/// focus; the gap then shows the dash's length, which the committed dash repeats as its gap. Tab
/// out of the gap commits nothing, and what is typed into the gap replaces all of that length.
#[test]
fn a_gap_field_passed_through_with_tab_commits_nothing() {
use crate::panels::stroke::{dash_fields, dash_pattern};
let shown = Cell::new([None; 6]);
let (dash, gap) = (Cell::new(None), Cell::new(None));
let draw = |ui: &mut egui::Ui| -> Option<f64> {
let [d, g, ..] = shown.get();
dash.set(widgets::opt_field(ui, "dash", d, Unit::Points, 60.0));
gap.set(widgets::opt_field(ui, "gap", g, Unit::Points, 60.0));
None
};
// One frame, then its commit applied the way the Stroke panel applies it. → What the gap
// committed.
let step = |f: &mut Field, events: Vec<Event>| {
f.frame(events, &draw);
let (d, g) = (dash.take(), gap.take());
if d.is_some() || g.is_some() {
let mut fields = shown.get();
fields[0] = d.unwrap_or(fields[0]);
fields[1] = g.unwrap_or(fields[1]);
shown.set(dash_fields(&dash_pattern(&fields)));
}
g
};
let tab = || key(Key::Tab, Modifiers::NONE);
for (typed, commits, pattern) in [(None, None, [20.0, 20.0]), (Some("5"), Some(Some(5.0)), [20.0, 5.0])] {
let mut f = Field::new();
shown.set([None; 6]);
step(&mut f, vec![]);
// Tab into the dash, type 20 and Tab on into the gap.
for events in [vec![tab()], vec![Event::Text("20".into())], vec![tab()], vec![]] {
assert_eq!(step(&mut f, events), None);
}
if let Some(text) = typed {
assert_eq!(step(&mut f, vec![Event::Text(text.into())]), None);
}
assert_eq!(step(&mut f, vec![tab()]), commits, "{typed:?}");
assert_eq!(step(&mut f, vec![]), None);
assert_eq!(dash_pattern(&shown.get()), pattern, "{typed:?}");
}
}
/// 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()
}
fn text_point(&self, text: &str, index: usize, margin: f32) -> Pos2 {
let galley = self.ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), egui::FontId::proportional(12.5), egui::Color32::BLACK));
let x = galley.pos_from_cursor(egui::text::CCursor::new(index)).left();
Pos2::new(self.rect.get().left() + margin + x + 0.1, self.rect.get().center().y)
}
/// 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 a_percentage_typed_in_a_field_is_a_share_of_the_value_before_it() {
let mut f = Field::new();
let cm = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(72.0), Unit::Centimeters, 80.0);
f.frame(vec![], &cm);
f.frame(f.click(1), &cm);
f.frame(vec![], &cm);
f.frame(vec![Event::Text("3cm*50%".into())], &cm);
let got = f.frame(vec![enter()], &cm).expect("the field reads `3cm*50%`");
assert!((got - Unit::Centimeters.to_pt(1.5)).abs() < 1e-9, "{got}");
}
#[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());
}
}
}
fn numeric_fixture(ui: &mut egui::Ui, mode: usize) -> Option<f64> {
match mode {
0 => widgets::num_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0),
1 => widgets::num_field(ui, "f", Some(123456.0), Unit::Points, 100.0),
2 => widgets::plain_field(ui, "f", 123456.0, "°", 0, 100.0),
_ => widgets::opt_field(ui, "f", Some(123456.0), Unit::Pixels, 100.0).flatten(),
}
}
fn field_text(mode: usize) -> &'static str {
["123456 px", "123456 pt", "123456°", "123456"][mode]
}
fn pointer_button(at: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
#[test]
fn numeric_drag_keeps_its_press_position_with_or_without_prior_focus() {
for mode in 0..4 {
for focused in [false, true] {
for (start, end) in [(1, 4), (4, 1)] {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
f.frame(vec![], &draw);
if focused {
f.frame(f.click(1), &draw);
f.frame(vec![], &draw);
f.time += 1.0;
}
let margin = if mode == 3 { 5.0 } else { 7.0 };
let at = f.text_point(field_text(mode), start, margin);
let to = f.text_point(field_text(mode), end, margin);
f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
assert_eq!(f.selection(), Some((start, start)), "mode {mode}, prior focus {focused}");
f.frame(vec![], &draw); // Holding still must leave the press position alone.
f.frame(vec![Event::PointerMoved(to)], &draw);
f.frame(vec![pointer_button(to, false)], &draw);
assert_eq!(f.selection(), Some((1, 4)), "mode {mode}, {start}->{end}, prior focus {focused}");
f.frame(vec![Event::Text("7".into())], &draw);
assert_eq!(f.frame(vec![enter()], &draw), Some(1756.0), "replace only the dragged text");
}
}
}
}
#[test]
fn a_new_numeric_click_selects_all_on_release_without_moving_its_press_caret() {
let mut f = Field::new();
f.num(vec![]);
let at = f.rect.get().center();
f.num(vec![Event::PointerMoved(at), pointer_button(at, true)]);
assert!(f.selection().is_some_and(|(a, b)| a == b), "a hold can still start a text drag");
f.num(vec![]);
assert!(f.selection().is_some_and(|(a, b)| a == b));
f.num(vec![pointer_button(at, false)]);
assert_eq!(f.selection(), Some((0, "100 px".len())));
f.num(vec![Event::Text("7".into())]);
assert_eq!(f.num(vec![enter()]), Some(7.0));
}
/// #1326: a short drag that the toolkit still counts as a click must keep its caret on
/// release instead of selecting the whole value.
#[test]
fn a_short_drag_released_as_a_click_keeps_its_caret() {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0);
f.frame(vec![], &draw);
let at = f.text_point(field_text(0), 2, 7.0);
f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
let pressed = f.selection();
assert!(pressed.is_some_and(|(a, b)| a == b), "the press places a caret");
// Moved a few points: inside one character, so egui reports a click, not a text selection.
let nudge = egui::vec2(3.4, 0.0);
f.frame(vec![Event::PointerMoved(at + nudge)], &draw);
f.frame(vec![pointer_button(at + nudge, false)], &draw);
assert_eq!(f.selection(), pressed, "the short drag keeps its caret instead of selecting all");
assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a clicked short drag must not select all (#1326)");
f.frame(vec![Event::Text("7".into())], &draw);
assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret");
}
/// A tap (no movement) on a fresh numeric field still selects all on release.
#[test]
fn a_tap_without_movement_still_selects_all_on_release() {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0);
f.frame(vec![], &draw);
let at = f.text_point(field_text(0), 2, 7.0);
f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw);
f.frame(vec![], &draw);
f.frame(vec![pointer_button(at, false)], &draw);
assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a plain tap selects all");
}
#[test]
fn keyboard_focus_keeps_selecting_the_entire_numeric_field() {
for mode in 0..4 {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| numeric_fixture(ui, mode);
f.frame(vec![], &draw);
f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw);
f.frame(vec![], &draw);
assert_eq!(f.selection(), Some((0, field_text(mode).chars().count())), "mode {mode}");
}
}
/// #1637: text typed into a field is committed when the focus leaves it by a click on empty space
/// or by a press on a field drawn before it (another channel of the Color panel), whether the
/// window reports keyboard focus or not, as Enter commits it. Escape still drops it.
#[test]
fn typed_text_is_committed_when_the_focus_moves_away() {
for focused in [true, false] {
for onto_field_above in [false, true] {
let mut f = Field::new();
f.focused = focused;
let above = Cell::new(Rect::NOTHING);
let draw = |ui: &mut egui::Ui| {
widgets::range_field(ui, "above", 50.0, 0.0..=1000.0, "%", 0, 80.0);
above.set(ui.min_rect());
widgets::range_field(ui, "f", 100.0, 0.0..=1000.0, "%", 0, 80.0)
};
f.frame(vec![], &draw);
let field = Pos2::new(above.get().center().x, (above.get().bottom() + f.rect.get().bottom()) / 2.0);
let away = if onto_field_above { above.get().center() } else { Pos2::new(field.x, 180.0) };
let case = format!("window focus {focused}, onto the field above {onto_field_above}");
f.frame(vec![Event::PointerMoved(field), pointer_button(field, true)], &draw);
f.frame(vec![pointer_button(field, false)], &draw);
f.frame(vec![Event::Text("42".into())], &draw);
let committed = [
f.frame(vec![Event::PointerMoved(away), pointer_button(away, true)], &draw),
f.frame(vec![pointer_button(away, false)], &draw),
f.frame(vec![], &draw),
];
assert_eq!(committed.iter().flatten().copied().collect::<Vec<_>>(), [42.0], "{case}");
f.frame(vec![Event::PointerMoved(field), pointer_button(field, true)], &draw);
f.frame(vec![pointer_button(field, false)], &draw);
f.frame(vec![Event::Text("7".into())], &draw);
let escaped = [f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw), f.frame(vec![], &draw)];
assert_eq!(escaped, [None, None], "{case}");
}
}
}
/// Text typed into a field is committed in the frame of a press elsewhere, before what is drawn
/// after the field (the canvas) acts on the press: a click that selects another object must not
/// get the typed value, even when both objects show the same one.
#[test]
fn a_press_on_the_canvas_commits_to_the_object_being_edited() {
let mut f = Field::new();
// The object the panel shows; both have the value 100.
let selected = Cell::new(1);
let committed = Cell::new(None);
let draw = |ui: &mut egui::Ui| {
let got = widgets::range_field(ui, "f", 100.0, 0.0..=1000.0, "%", 0, 80.0);
if let Some(v) = got {
committed.set(Some((selected.get(), v)));
}
// The canvas: a press ends text input and selects the other object.
if ui.input(|i| i.pointer.primary_pressed() && i.pointer.interact_pos().is_some_and(|p| p.y > 100.0)) {
ui.memory_mut(|m| m.stop_text_input());
selected.set(2);
}
got
};
f.frame(vec![], &draw);
let (field, canvas) = (f.rect.get().center(), Pos2::new(200.0, 180.0));
f.frame(vec![Event::PointerMoved(field), pointer_button(field, true)], &draw);
f.frame(vec![pointer_button(field, false)], &draw);
f.frame(vec![Event::Text("42".into())], &draw);
f.frame(vec![Event::PointerMoved(canvas), pointer_button(canvas, true)], &draw);
f.frame(vec![pointer_button(canvas, false)], &draw);
f.frame(vec![], &draw);
assert_eq!((committed.get(), selected.get()), (Some((1, 42.0)), 2));
}