Files
vectorcraft/crates/ui-egui/src/tests/numfields.rs
T
BFlat da3affd8bf Merge pull request #6897 from dotCooCoo/fix/number-wheel-smoothing-tail
Number fields: the rest of a wheel notch a field took no longer scrolls the panel
2026-11-12 23:08:54 +07:00

1717 lines
82 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, RefCell};
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);
}
/// A field with steps of its own (the Pencil's Fidelity, #3328): ↑/↓ take them instead of whole
/// numbers, ten at once with Shift, and Ctrl/Cmd steps as plainly as no modifier does.
#[test]
fn a_stepped_field_takes_its_own_steps() {
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_stepped(ui, "f", value, 0.01..=100.0, "pt", 2, 80.0, |v, n| ((v + 0.01 * n) * 100.0).round() / 100.0);
got.1 = ui.min_rect();
});
out.textures_delta.clear();
got
};
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
let at = frame(0.02, vec![]).1.center();
frame(0.02, vec![Event::PointerMoved(at), button(at, true)]);
frame(0.02, vec![button(at, false)]);
assert_eq!(frame(0.02, vec![key(Key::ArrowUp, Modifiers::NONE)]).0, Some(0.03), "↑ takes one of its steps, not a whole point");
assert_eq!(frame(0.03, vec![key(Key::ArrowDown, Modifiers::NONE)]).0, Some(0.02));
assert_eq!(frame(0.02, vec![key(Key::ArrowUp, Modifiers::SHIFT)]).0, Some(0.12), "Shift takes ten");
assert_eq!(frame(0.12, vec![key(Key::ArrowUp, Modifiers::COMMAND)]).0, Some(0.13), "Cmd takes one");
// The steps stop at the field's range.
assert_eq!(frame(0.01, vec![key(Key::ArrowDown, Modifiers::NONE)]).0, None, "nothing below 0.01");
}
/// #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.scope_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()]);
}
}
/// Keyboard focus selects a hex field's digits. A pasted code replaces them wherever the caret is,
/// hex digits that fit edit them in place, and a paste that is not a color leaves them as they were.
/// Typing digit by digit is not affected.
#[test]
fn a_paste_into_a_hex_field_replaces_or_edits_its_digits() {
let hex = Cell::new(None);
let hex_id = Cell::new(egui::Id::NULL);
let draw = |ui: &mut egui::Ui| {
let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0);
hex_id.set(ui.scope_id().with("hex"));
hex.set(widgets::hex_field(ui, "hex", "FF8000"));
red
};
let (tab, end) = (|| key(Key::Tab, Modifiers::NONE), || key(Key::End, Modifiers::NONE));
let paste = |text: &str| vec![Event::Paste(text.into())];
let digit = |text: &str| vec![Event::Text(text.into())];
// Its name, the events after the field has the focus, the text they leave and what Tab commits.
type Case<'a> = (&'a str, Vec<Vec<Event>>, &'a str, Option<&'a str>);
let select_left = |n: usize| std::iter::once(end()).chain(std::iter::repeat_n(key(Key::ArrowLeft, Modifiers::SHIFT), n)).collect::<Vec<_>>();
let cases: [Case; 25] = [
("three selected, three digits", vec![select_left(3), paste("ABC")], "FF8ABC", Some("FF8ABC")),
("three characters, caret, three digits", vec![digit("FF8"), paste("ABC")], "FF8ABC", Some("FF8ABC")),
("five characters, two selected, three digits", vec![digit("12345"), select_left(2), paste("ABC")], "123ABC", Some("123ABC")),
("two characters, caret, three digits", vec![digit("12"), paste("ABC")], "ABC", Some("AABBCC")),
("four selected, three digits", vec![select_left(4), paste("0f0")], "0f0", Some("00ff00")),
("selected digits", vec![paste("00FF00")], "00FF00", Some("00FF00")),
("caret, a code", vec![vec![end()], paste("00FF00")], "00FF00", Some("00FF00")),
("caret, a code with #", vec![vec![end()], paste("#00ff00")], "00ff00", Some("00ff00")),
("caret, three digits", vec![vec![end()], paste("0f0")], "0f0", Some("00ff00")),
("caret, a line break after the code", vec![vec![end()], paste("#00FF00\n")], "00FF00", Some("00FF00")),
("caret, a word", vec![vec![end()], paste("hello")], "FF8000", None),
("caret, too few digits", vec![vec![end()], paste("12")], "FF8000", None),
("selected digits, too many digits", vec![paste("12345678")], "FF8000", None),
("part selected, digits that fit", vec![select_left(2), paste("AB")], "FF80AB", Some("FF80AB")),
("typed # and two digits, four digits", vec![digit("#12"), paste("3456")], "#123456", Some("123456")),
("typed # and three digits, three digits", vec![digit("#FFF"), paste("ABC")], "#FFFABC", Some("FFFABC")),
("typed # and two digits, three digits", vec![digit("#12"), paste("ABC")], "ABC", Some("AABBCC")),
("typed #, two digits", vec![digit("#"), paste("12")], "#12", Some("12")),
("empty field, two digits", vec![vec![key(Key::Backspace, Modifiers::NONE)], paste("12")], "12", Some("12")),
("the same color in other letters", vec![paste("ff8000")], "ff8000", None),
("typed digit by digit", ["0", "0", "F", "F", "0", "0"].map(digit).to_vec(), "00FF00", Some("00FF00")),
("typed with #", vec![digit("#00FF00")], "#00FF00", Some("00FF00")),
("typed in part", vec![digit("00")], "00", Some("00")),
("typed three digits with #", vec![digit("#0f0")], "#0f0", Some("00ff00")),
("typed three letters that are not digits", vec![digit("xyz")], "xyz", Some("xyz")),
];
for (name, events, shows, commits) in cases {
let mut f = Field::new();
f.frame(vec![], &draw);
f.frame(vec![tab()], &draw);
f.frame(vec![tab()], &draw);
f.frame(vec![], &draw);
let selected = egui::TextEdit::load_state(&f.ctx, hex_id.get()).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range());
assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 6)), "{name}: keyboard focus selects the digits");
for events in events {
f.frame(events, &draw);
assert_eq!(hex.take(), None, "{name}: nothing is committed before Tab");
}
assert_eq!(f.ctx.data(|d| d.get_temp::<String>(hex_id.get())).as_deref(), Some(shows), "{name}: the text");
f.frame(vec![tab()], &draw);
assert_eq!(hex.take().as_deref(), commits, "{name}: what Tab commits");
}
// Escape drops a pasted code.
let mut f = Field::new();
f.frame(vec![], &draw);
f.frame(vec![tab()], &draw);
f.frame(vec![tab()], &draw);
f.frame(paste("00FF00"), &draw);
assert_eq!(hex.take(), None);
f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw);
assert_eq!(hex.take(), None, "Escape commits nothing");
f.frame(vec![], &draw);
assert_eq!(f.ctx.data(|d| d.get_temp::<String>(hex_id.get())).as_deref(), Some("FF8000"), "the field shows its color again");
// A press outside the field commits in the frame of the press, before the press acts.
let mut f = Field::new();
f.frame(vec![], &draw);
f.frame(vec![tab()], &draw);
f.frame(vec![tab()], &draw);
f.frame(paste("00FF00"), &draw);
let away = Pos2::new(390.0, 190.0);
f.frame(vec![Event::PointerMoved(away)], &draw);
assert_eq!(hex.take(), None);
f.frame(vec![Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], &draw);
assert_eq!(hex.take().as_deref(), Some("00FF00"), "the press commits");
// Three digits that spell the color the field holds commit nothing.
let same = |ui: &mut egui::Ui| {
let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0);
hex.set(widgets::hex_field(ui, "hex", "AABBCC"));
red
};
let mut f = Field::new();
f.frame(vec![], &same);
f.frame(vec![tab()], &same);
f.frame(vec![tab()], &same);
f.frame(paste("abc"), &same);
assert_eq!(hex.take(), None);
f.frame(vec![tab()], &same);
assert_eq!(hex.take(), None, "abc over AABBCC commits nothing");
}
/// #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:?}");
}
}
/// #3497: committing a visible dash field must not replace an extended pattern with six values.
/// Passing through the next field without typing must not commit anything else.
#[test]
fn dash_tab_and_enter_commits_preserve_the_unshown_pattern_tail() {
use crate::panels::stroke::{dash_fields, edited_dash_pattern};
for end in [key(Key::Tab, Modifiers::NONE), enter()] {
let original = vec![12.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0, 10.0];
let pattern = RefCell::new(original.clone());
let edits = RefCell::new([None; 6]);
let draw = |ui: &mut egui::Ui| {
let fields = dash_fields(&pattern.borrow());
for (i, value) in fields.iter().enumerate() {
edits.borrow_mut()[i] = widgets::opt_field(ui, ("dash", i), *value, Unit::Points, 40.0);
}
None
};
let step = |f: &mut Field, events: Vec<Event>| {
f.frame(events, &draw);
let mut fields = dash_fields(&pattern.borrow());
let mut changed = false;
for (field, edit) in fields.iter_mut().zip(edits.borrow_mut().iter_mut()) {
if let Some(value) = edit.take() {
*field = value;
changed = true;
}
}
if changed {
let next = edited_dash_pattern(&fields, &pattern.borrow());
*pattern.borrow_mut() = next;
}
changed
};
let mut f = Field::new();
let tab = || key(Key::Tab, Modifiers::NONE);
for events in [vec![], vec![tab()], vec![], vec![tab()], vec![]] {
assert!(!step(&mut f, events));
}
assert_eq!(*pattern.borrow(), original, "tabbing without editing changes nothing");
// Shift+Tab back to the first dash, replace it, then commit with Tab or Enter.
for events in [vec![key(Key::Tab, Modifiers::SHIFT)], vec![], vec![Event::Text("14".into())]] {
assert!(!step(&mut f, events));
}
assert!(step(&mut f, vec![end]));
assert!(!step(&mut f, vec![]));
assert_eq!(*pattern.borrow(), vec![14.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0, 10.0]);
assert!(!step(&mut f, vec![tab()]));
assert!(!step(&mut f, vec![]));
}
}
/// 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.scope_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 }
}
/// What [`preset_row`]'s fields committed or picked in a frame: the two numbers, the text and the
/// pick of the field with an Auto entry.
type PresetPicks = (Option<f64>, Option<f64>, Option<String>, Option<widgets::SpinPick>);
const NO_PICK: PresetPicks = (None, None, None, None);
thread_local! {
/// Whether the last [`preset_row`] frame left an Enter press for a dialog drawn after it.
static ENTER_LEFT: Cell<bool> = const { Cell::new(false) };
}
/// One frame in `ctx` with `events` of a row of two numeric fields with a spinner and presets
/// (Stroke Weight, and a font size that is none of its presets), a text field with presets (Export
/// for Screens' scale) and a numeric field whose list starts with Auto, which is on (Leading).
fn preset_row(ctx: &egui::Context, time: &Cell<f64>, events: Vec<Event>) -> PresetPicks {
time.set(time.get() + 0.05);
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0)));
let mut got = NO_PICK;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
got.0 = widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0, 4.0]);
got.1 = widgets::spin_field(ui, "size", Some(13.0), Unit::Points, 100.0, 1.0, 0.0, &[9.0, 12.0]);
got.2 = widgets::text_presets(ui, "scale", "1x", &["1x", "2x"], 100.0);
got.3 = widgets::spin_field_auto(ui, "leading", Some(14.0), Some(true), Unit::Points, 100.0, 1.0, 0.0, &[12.0, 14.0]);
});
ENTER_LEFT.set(crate::shortcuts::pressed_anew(ui.ctx(), Key::Enter));
});
out.textures_delta.clear();
got
}
/// Tab goes from one field to the next in one press: it does not stop on a field's spinner or on
/// the chevron that opens its presets, and Shift+Tab goes back the same way.
#[test]
fn tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
preset_row(&ctx, &time, vec![]);
preset_row(&ctx, &time, vec![]);
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 8, |events| {
preset_row(&ctx, &time, events);
});
let widths: Vec<f32> = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect();
assert_eq!(widths.len(), 8, "{stops:?}");
assert!(widths.iter().all(|w| *w > 40.0), "only the fields are Tab stops, not the spinners or chevrons: {widths:?}");
assert_eq!(stops[..4], stops[4..], "four fields, then back to the first: {widths:?}");
let mut back = vec![];
for _ in 0..2 {
preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::SHIFT)]);
preset_row(&ctx, &time, vec![]);
back.extend(ctx.memory(|m| m.focused()));
}
assert_eq!(back, [stops[2], stops[1]], "Shift+Tab goes back field by field");
}
/// The Transparency row's opacity slider chevron is not a Tab stop either: Tab goes from the
/// Opacity field straight to the next field.
#[test]
fn tab_goes_from_the_opacity_field_past_its_slider_chevron() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let frame = |events: Vec<Event>| {
time.set(time.get() + 0.05);
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0)));
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
widgets::opacity_blend(ui, "transparency", Some(0.5), None, true);
widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[]);
});
});
out.textures_delta.clear();
};
frame(vec![]);
frame(vec![]);
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 6, frame);
let widths: Vec<f32> = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect();
assert_eq!(widths.len(), 6, "{stops:?}");
assert!(widths.iter().all(|w| *w > 20.0), "the 18 pt slider chevron is not a Tab stop: {widths:?}");
assert_eq!(stops[..3], stops[3..], "blend mode, Opacity and the next field, then back: {widths:?}");
}
/// Gives the keyboard focus to the field that `tabs` presses of Tab reach in [`preset_row`], with
/// every list closed, and returns that field.
fn focus_preset_field(ctx: &egui::Context, time: &Cell<f64>, tabs: usize) -> Option<egui::Id> {
egui::Popup::close_all(ctx);
ctx.memory_mut(|m| m.surrender_focus(m.focused().unwrap_or(egui::Id::NULL)));
preset_row(ctx, time, vec![]);
for _ in 0..tabs {
preset_row(ctx, time, vec![key(Key::Tab, Modifiers::NONE)]);
}
preset_row(ctx, time, vec![]);
ctx.memory(|m| m.focused())
}
/// The presets stay in reach of the keyboard: Alt+↓ in a field opens its preset list with the
/// keyboard focus on the chosen preset (the first entry when none is chosen, Auto when it is on),
/// Tab goes on to the next one and Enter picks it, without reaching a dialog's OK. The field then
/// has the keyboard focus again.
#[test]
fn alt_down_opens_a_fields_presets_for_the_keyboard() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let alt_down = || key(Key::ArrowDown, Modifiers::ALT);
// (Tabs to the field, the pick after Tab and Enter in its list.)
let cases = [
(1, (Some(4.0), None, None, None)),
(2, (None, Some(12.0), None, None)),
(3, (None, None, Some("2x".to_string()), None)),
(4, (None, None, None, Some(widgets::SpinPick::Value(12.0)))),
];
for (tabs, picked) in cases {
let field = focus_preset_field(&ctx, &time, tabs);
assert!(field.is_some(), "{tabs}");
assert_eq!(preset_row(&ctx, &time, vec![alt_down()]), NO_PICK, "{tabs}");
preset_row(&ctx, &time, vec![]);
let entry = ctx.memory(|m| m.focused());
assert!(entry.is_some() && entry != field, "{tabs}: the list takes the focus");
let layer = ctx.read_response(entry.unwrap_or(egui::Id::NULL)).map(|r| r.layer_id.order);
assert_eq!(layer, Some(egui::Order::Foreground), "{tabs}: on an entry of the list");
preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::NONE)]);
preset_row(&ctx, &time, vec![]);
let mut got = preset_row(&ctx, &time, vec![enter()]);
assert!(!ENTER_LEFT.get(), "{tabs}: the list takes the Enter, so a dialog's OK does not");
if got == NO_PICK {
got = preset_row(&ctx, &time, vec![]);
}
assert_eq!(got, picked, "{tabs}: Enter picks the next preset");
preset_row(&ctx, &time, vec![]);
preset_row(&ctx, &time, vec![]);
assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed");
assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again");
}
}
/// Escape closes a preset list that Alt+↓ opened without picking anything, and the field has the
/// keyboard focus again.
#[test]
fn escape_closes_a_fields_presets_and_returns_to_the_field() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
for tabs in [1, 3] {
let field = focus_preset_field(&ctx, &time, tabs);
preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::ALT)]);
preset_row(&ctx, &time, vec![]);
assert!(egui::Popup::is_any_open(&ctx), "{tabs}: Alt+↓ opened the list");
let mut picks = vec![preset_row(&ctx, &time, vec![key(Key::Escape, Modifiers::NONE)])];
picks.push(preset_row(&ctx, &time, vec![]));
picks.push(preset_row(&ctx, &time, vec![]));
assert!(picks.iter().all(|p| *p == NO_PICK), "{tabs}: nothing picked: {picks:?}");
assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed");
assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again");
}
}
/// Only Alt+↓ with no other modifier opens the presets: Shift+Alt+↓ in a field with presets
/// steps it down by ten, as before, and leaves the list closed.
#[test]
fn shift_alt_down_still_steps_a_field_with_presets() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let field = focus_preset_field(&ctx, &time, 2);
let mut got = preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::SHIFT | Modifiers::ALT)]);
if got == NO_PICK {
got = preset_row(&ctx, &time, vec![]);
}
assert_eq!(got.1, Some(3.0), "13 pt steps down by ten");
assert!(!egui::Popup::is_any_open(&ctx), "the list stays closed");
assert_eq!(ctx.memory(|m| m.focused()), field);
}
#[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));
}
/// Shift+Enter applies what is typed into a field and keeps the field active, its new value all
/// selected so typing replaces it; Enter then ends its editing. In a dialog, Shift+Enter ends the
/// editing as Enter does.
#[test]
fn shift_enter_applies_the_value_and_keeps_the_field_active() {
let shift_enter = || key(Key::Enter, Modifiers::SHIFT);
let has_focus = |f: &Field| f.ctx.memory(|m| m.has_focus(f.id.get()));
// A unit field, a plain field with a suffix and a field that may be empty, each applying what
// it returns. → (text typed, value applied, text shown afterwards).
let px = Cell::new(100.0);
let num = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(px.get()), Unit::Pixels, 80.0).inspect(|&v| px.set(v));
let deg = Cell::new(10.0);
let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", deg.get(), "°", 2, 80.0).inspect(|&v| deg.set(v));
let dash = Cell::new(Some(20.0));
let opt = |ui: &mut egui::Ui| widgets::opt_field(ui, "f", dash.get(), Unit::Points, 60.0).map(|v| dash.set(v)).and(dash.get());
type Draw<'a> = &'a dyn Fn(&mut egui::Ui) -> Option<f64>;
let fields: [(&str, Draw, &str, f64, &str); 3] =
[("num_field", &num, "150", 150.0, "150 px"), ("plain_field", &plain, "45", 45.0, "45°"), ("opt_field", &opt, "2+3", 5.0, "5")];
for (name, draw, typed, want, shows) in fields {
let mut f = Field::new();
f.frame(vec![], draw);
f.frame(f.click(1), draw);
f.frame(vec![], draw);
f.frame(vec![Event::Text(typed.into())], draw);
assert_eq!(f.frame(vec![shift_enter()], draw), Some(want), "{name}: Shift+Enter applies the value");
assert!(has_focus(&f), "{name}: the field keeps the focus");
assert_eq!(f.frame(vec![], draw), None, "{name}: applied once");
assert_eq!(f.selection(), Some((0, shows.chars().count())), "{name}: the value shown is selected");
assert_eq!(f.ctx.data(|d| d.get_temp::<String>(f.id.get())).as_deref(), Some(shows), "{name}");
// Typing replaces it, and Enter applies that and ends the editing.
f.frame(vec![Event::Text("2".into())], draw);
assert_eq!(f.frame(vec![enter()], draw), Some(2.0), "{name}: Enter after it");
assert!(!has_focus(&f), "{name}: Enter ends the editing");
}
// A text field and a hex field (its color shown in capitals) the same way; a field of several
// lines takes Shift+Enter as a line break.
let (text, hex, hex_id) = (Cell::new(None), Cell::new(None), Cell::new(egui::Id::NULL));
let color = RefCell::new(String::from("FF8000"));
let draw = |ui: &mut egui::Ui| {
text.set(widgets::text_field(ui, "f", Some("Name"), 120.0, 1).or(text.take()));
hex_id.set(ui.scope_id().with("hex"));
let shown = color.borrow().clone();
let applied = widgets::hex_field(ui, "hex", &shown);
if let Some(h) = &applied {
color.replace(h.to_uppercase());
}
hex.set(applied.or(hex.take()));
None
};
let mut f = Field::new();
f.frame(vec![], &draw);
f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw);
f.frame(vec![key(Key::A, Modifiers::COMMAND)], &draw);
f.frame(vec![Event::Text("Logo".into()), shift_enter()], &draw);
assert_eq!(text.take().as_deref(), Some("Logo"));
assert!(has_focus(&f), "the text field keeps the focus");
f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw);
f.frame(vec![key(Key::A, Modifiers::COMMAND)], &draw);
f.frame(vec![Event::Text("00ff00".into()), shift_enter()], &draw);
assert_eq!((text.take(), hex.take().as_deref()), (None, Some("00ff00")));
assert!(f.ctx.memory(|m| m.has_focus(hex_id.get())), "the hex field keeps the focus");
f.frame(vec![], &draw);
assert_eq!(f.ctx.data(|d| d.get_temp::<String>(hex_id.get())).as_deref(), Some("00FF00"), "the hex field shows the color applied");
let selected = egui::TextEdit::load_state(&f.ctx, hex_id.get()).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range());
assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 6)), "the hex field's color is selected");
let lines = Cell::new(None);
let draw = |ui: &mut egui::Ui| {
lines.set(widgets::text_field(ui, "f", Some(""), 120.0, 3).or(lines.take()));
None
};
let mut f = Field::new();
f.frame(vec![], &draw);
f.frame(f.click(1), &draw);
f.frame(vec![Event::Text("a".into()), shift_enter(), Event::Text("b".into())], &draw);
assert!(has_focus(&f) && lines.take().is_none(), "a line break, nothing applied");
f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw);
// In a dialog, Shift+Enter applies the value and ends the editing.
let mut f = Field::new();
widgets::set_dialog_open(&f.ctx, true);
px.set(100.0);
f.frame(vec![], &num);
f.frame(f.click(1), &num);
f.frame(vec![Event::Text("150".into())], &num);
assert_eq!(f.frame(vec![shift_enter()], &num), Some(150.0));
assert!(!has_focus(&f), "a dialog's field ends its editing");
}
/// 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, 14.0),
] {
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, "14 mm".len())), "the new value is selected");
// Enter after the steps commits nothing more.
assert_eq!(f.frame(vec![enter()], &draw), None);
}
/// #2881, #2793: ↑/↓ and the wheel step a fractional value to the next whole unit in their
/// direction (5.1 mm: up to 6, down to 5), and Ctrl/Cmd steps to the next tenth; Shift adds or takes
/// ten and keeps the fraction. In each field's own unit: millimeters, points, percent, degrees.
#[test]
fn steps_go_to_the_next_whole_unit_or_tenth() {
use egui::MouseWheelUnit::Line;
let u = Unit::Millimeters;
let mm = Cell::new(0.0);
let mm_field = |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)));
let mut f = Field::new();
f.frame(vec![], &mm_field);
f.frame(f.click(1), &mm_field);
f.frame(vec![], &mm_field);
let at = f.rect.get().center();
// From `from` (set from outside, which the focused field follows), the value `events` give.
let mut step = |from: f64, events: Vec<Event>| {
mm.set(from);
f.frame(vec![], &mm_field);
assert!(f.frame(events, &mm_field).is_some(), "from {from}");
// The field shows the new value before the next one is set.
f.frame(vec![], &mm_field);
mm.get()
};
let arrow = |k, mods| vec![key(k, mods)];
let turn = |dy, mods| wheel(at, Line, dy, mods);
for (from, events, want) in [
(5.1, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.1, arrow(Key::ArrowDown, Modifiers::NONE), 5.0),
(5.0, arrow(Key::ArrowUp, Modifiers::NONE), 6.0),
(5.0, arrow(Key::ArrowDown, Modifiers::NONE), 4.0),
(-2.5, arrow(Key::ArrowUp, Modifiers::NONE), -2.0),
(-2.5, arrow(Key::ArrowDown, Modifiers::NONE), -3.0),
(5.13, arrow(Key::ArrowUp, Modifiers::COMMAND), 5.2),
(5.13, arrow(Key::ArrowDown, Modifiers::COMMAND), 5.1),
(5.1, arrow(Key::ArrowUp, Modifiers::SHIFT), 15.1),
(5.1, arrow(Key::ArrowDown, Modifiers::SHIFT), -4.9),
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::NONE)], 7.0),
// Several presses in one frame step in the order they came.
(5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowDown, Modifiers::NONE)], 5.0),
(5.13, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::COMMAND)], 6.1),
(24.8, turn(1.0, Modifiers::NONE), 25.0),
(24.8, turn(-1.0, Modifiers::NONE), 24.0),
(24.8, turn(2.0, Modifiers::NONE), 26.0),
(24.8, turn(-2.0, Modifiers::NONE), 23.0),
(24.0, turn(1.0, Modifiers::NONE), 25.0),
(24.83, turn(1.0, Modifiers::COMMAND), 24.9),
(24.8, turn(1.0, Modifiers::SHIFT), 34.8),
] {
let got = step(from, events);
assert!((got - want).abs() < 1e-6, "{from} mm to {got}, not {want}");
}
// A points field steps whole points.
let pt = Cell::new(12.25);
let pt_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), Unit::Points, 80.0).inspect(|&v| pt.set(v));
let mut f = Field::new();
f.frame(vec![], &pt_field);
f.frame(f.click(1), &pt_field);
f.frame(vec![], &pt_field);
assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &pt_field), Some(13.0));
// Percent and degree fields step whole percents and degrees.
for suffix in ["%", "°"] {
let mut f = Field::new();
let value = Cell::new(37.5);
let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), suffix, 2, 80.0).inspect(|&v| value.set(v));
f.frame(vec![], &plain);
f.frame(f.click(1), &plain);
f.frame(vec![], &plain);
for (k, mods, want) in [
(Key::ArrowDown, Modifiers::NONE, 37.0),
(Key::ArrowUp, Modifiers::NONE, 38.0),
(Key::ArrowUp, Modifiers::COMMAND, 38.1),
(Key::ArrowUp, Modifiers::NONE, 39.0),
] {
assert_eq!(f.frame(vec![key(k, mods)], &plain), Some(want), "{suffix} {k:?} {mods:?}");
f.frame(vec![], &plain);
}
}
}
/// 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, source: egui::MouseWheelSource::Unknown },
]
}
/// Hover stepping (#5949) keeps the existing units, modifiers and trackpad accumulation,
/// without taking keyboard focus. Outside the field, the wheel is left alone.
#[test]
fn the_wheel_steps_a_hovered_field_without_taking_focus() {
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();
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, 24.0)]
{
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!(!f.ctx.memory(|m| m.has_focus(f.id.get())), "hover never takes keyboard focus");
// 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() - 25.0).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));
}
/// Both focused and unfocused hovered fields own the wheel, including its smoothing tail.
#[test]
fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() {
for focus in [false, true] {
let mut f = Field::new();
let offset = Cell::new(0.0);
let value = Cell::new(10.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", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
ui.add_space(600.0);
v
});
offset.set(out.state.clamped_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);
}
assert_eq!((stepped, offset.get()), (Some(8.0), 0.0), "focus {focus}");
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), None);
assert_eq!(offset.get(), 0.0);
assert_eq!(f.frame(wheel(at, egui::MouseWheelUnit::Point, -line * 0.6, Modifiers::NONE), &draw), Some(7.0));
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!(offset.get(), 0.0);
assert_eq!(f.frame(wheel(Pos2::new(20.0, 45.0), egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw), None);
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert!(offset.get() > 0.0, "outside the field scrolls the panel");
}
}
/// A wheel notch of `dy` lines (up positive) from `source`, with no pointer movement.
fn notch(dy: f32, source: egui::MouseWheelSource) -> Event {
Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, source }
}
/// A notch down from a mouse wheel.
fn notch_down() -> Event {
notch(-1.0, egui::MouseWheelSource::Wheel)
}
/// A panel scrolled with a still pointer keeps scrolling when a number field slides under the
/// pointer, and the field keeps its value. The field used to take each notch that came while it
/// was under the pointer.
#[test]
fn scrolling_a_panel_past_a_number_field_under_a_still_pointer_keeps_the_value() {
let mut f = Field::new();
let offset = Cell::new(0.0);
let value = Cell::new(10.0);
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.add_space(60.0);
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
ui.add_space(600.0);
v
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![Event::PointerMoved(Pos2::new(20.0, 20.0))], &draw);
let mut stepped = None;
for _ in 0..30 {
stepped = stepped.or(f.frame(vec![notch_down()], &draw));
}
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!((stepped, value.get()), (None, 10.0), "the field kept its value");
assert!(offset.get() > 200.0, "the panel scrolled the field past the pointer: {}", offset.get());
}
/// A number field that slid under a still pointer takes the wheel only after the pointer is moved
/// onto it; an event at the same position is not a move. Until then the panel scrolls.
#[test]
fn a_number_field_that_slid_under_a_still_pointer_steps_after_the_pointer_moves() {
let mut f = Field::new();
let top = Cell::new(60.0);
let value = Cell::new(10.0);
let draw = |ui: &mut egui::Ui| {
ui.add_space(top.get());
widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v))
};
f.frame(vec![], &draw);
let at = Pos2::new(20.0, 20.0);
f.frame(vec![Event::PointerMoved(at)], &draw);
top.set(0.0);
f.frame(vec![], &draw);
assert!(f.rect.get().contains(at), "the field is under the pointer");
for _ in 0..2 {
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![notch_down()], &draw), None, "the pointer did not move");
}
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), None, "the same position");
f.time += crate::widgets::WHEEL_STREAM_GAP;
let moved = Pos2::new(21.0, 20.0);
assert_eq!(f.frame(vec![Event::PointerMoved(moved), notch_down()], &draw), Some(9.0));
// The field moves again under the pointer, in the frame of a turn: the turn is not the field's,
// and the field waits for another move.
top.set(2.0);
assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved in the frame of the turn");
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved");
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), Some(8.0));
}
/// A wheel stream stays with what it began on until it pauses for the stream gap: one that began
/// off the field never steps it, one that left the field (the pointer moved off it or out of the
/// window) does not come back to it, and trackpad momentum never starts one on the field.
#[test]
fn a_number_field_wheel_stream_stays_with_what_it_began_on() {
use egui::MouseWheelSource::{Momentum, Trackpad};
let mut f = Field::new();
let value = Cell::new(10.0);
let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
f.frame(vec![], &draw);
let (on, off) = (f.rect.get().center(), Pos2::new(300.0, 150.0));
let gap = |f: &mut Field| f.time += crate::widgets::WHEEL_STREAM_GAP;
// Began on the field, left it, came back within the stream.
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
assert_eq!(f.frame(vec![Event::PointerMoved(off), notch_down()], &draw), None);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left its field");
gap(&mut f);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0), "a new stream");
// Began off the field.
f.frame(vec![Event::PointerMoved(off)], &draw);
gap(&mut f);
assert_eq!(f.frame(vec![notch_down()], &draw), None);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "began off the field");
gap(&mut f);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(7.0));
// The pointer left the window.
assert_eq!(f.frame(vec![Event::PointerGone], &draw), None);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left the window");
// Momentum after a pause starts no stream on the field, and the stream it starts stays off it.
gap(&mut f);
assert_eq!(f.frame(vec![notch(-1.0, Momentum)], &draw), None, "momentum");
assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), None, "the momentum's stream");
gap(&mut f);
assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), Some(6.0));
assert_eq!(value.get(), 6.0);
}
/// A frame whose turns begin on the field and go on after the pointer moved off it scrolls the
/// panel with all of them and leaves the value.
#[test]
fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() {
let mut f = Field::new();
let offset = Cell::new(0.0);
let value = Cell::new(10.0);
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
ui.add_space(600.0);
v
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
let (on, off) = (Pos2::new(20.0, 12.0), Pos2::new(20.0, 80.0));
let events = vec![Event::PointerMoved(on), notch_down(), Event::PointerMoved(off), notch_down()];
assert_eq!(f.frame(events, &draw), None);
assert!(offset.get() > 0.0, "the panel scrolled in the same frame");
assert_eq!(value.get(), 10.0);
}
/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls
/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn
/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch.
#[test]
fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() {
for to_other_panel in [false, true] {
let mut f = Field::new();
let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
ui.horizontal_top(|ui| {
let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| {
ui.allocate_space(vec2(100.0, 600.0));
});
left.set(out.state.clamped_offset().y);
let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| {
ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
ui.add_space(600.0);
v
})
.inner
});
right.set(out.state.clamped_offset().y);
out.inner
})
.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) };
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
f.frame(vec![Event::PointerMoved(off)], &draw);
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}");
assert_eq!(f.frame(vec![notch_down()], &draw), None);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) };
assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}");
assert_eq!(value.get(), 9.0);
}
}
/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a
/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number
/// field takes after that still scrolls no panel after the pointer leaves.
#[test]
fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
ui.add_space(600.0);
v
})
.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
for _ in 0..20 {
f.frame(vec![], &draw);
}
let start = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::Vec2::ZERO,
phase: egui::TouchPhase::Start,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
f.frame(vec![start], &draw);
for _ in 0..10 {
f.frame(vec![], &draw);
}
assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0));
f.frame(vec![Event::PointerMoved(off)], &draw);
for _ in 0..20 {
f.frame(vec![], &draw);
}
assert_eq!((offset.get(), value.get()), (0.0, 8.0));
}
/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing
/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch.
#[test]
fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0));
for _ in 0..20 {
f.frame(vec![], &draw);
}
let touch = |phase| Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO },
phase,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)];
assert_eq!(f.frame(events, &draw), Some(8.0));
f.frame(vec![Event::PointerMoved(off), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get());
assert_eq!(value.get(), 8.0);
}
/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for
/// egui to let out later, so the next notch on the panel scrolls it by one whole notch.
#[test]
fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let on = field.get().center();
let off = Pos2::new(on.x, field.get().bottom() + 40.0);
let end = Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::Vec2::ZERO,
phase: egui::TouchPhase::End,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0));
f.frame(vec![Event::PointerMoved(off), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get());
assert_eq!(value.get(), 9.0);
}
/// A small turn the other way on the panel while the rest of a notch a number field took is still
/// being let out scrolls the panel by the whole turn.
#[test]
fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() {
let mut f = Field::new();
let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING));
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| {
ui.add_space(150.0);
let v = ui.vertical(|ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
field.set(ui.min_rect());
v
});
ui.add_space(600.0);
v.inner
});
offset.set(out.state.clamped_offset().y);
out.inner
};
f.frame(vec![], &draw);
f.frame(vec![], &draw);
let line = f.ctx.options(|o| o.input_options.line_scroll_speed);
let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0);
f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
let scrolled = offset.get();
assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}");
f.frame(vec![Event::PointerMoved(field.get().center())], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0));
let up = Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 10.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
source: egui::MouseWheelSource::Wheel,
};
f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw);
for _ in 0..30 {
f.frame(vec![], &draw);
}
assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get());
assert_eq!(value.get(), 9.0);
}
/// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it
/// again before it takes the wheel, with or without another number field drawn meanwhile.
#[test]
fn a_number_field_that_missed_a_pass_waits_for_a_pointer_move() {
for other in [false, true] {
let mut f = Field::new();
let shown = Cell::new(true);
let draw = |ui: &mut egui::Ui| {
let v = if shown.get() { widgets::plain_field(ui, "f", 10.0, "", 0, 80.0) } else { None };
if other {
ui.add_space(100.0);
widgets::plain_field(ui, "other", 1.0, "", 0, 80.0);
}
v
};
f.frame(vec![], &draw);
let at = Pos2::new(20.0, 12.0);
f.frame(vec![Event::PointerMoved(at)], &draw);
shown.set(false);
f.frame(vec![], &draw);
shown.set(true);
f.frame(vec![], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), None, "missed a pass, other field {other}");
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0), "other field {other}");
}
}
/// Two number fields, `a` above `b`; the frame returns `b`'s new value, and `a`'s goes in `a`.
fn two_fields<'a>(a: &'a Cell<f64>, b: &'a Cell<f64>, rects: &'a Cell<[Rect; 2]>) -> impl Fn(&mut egui::Ui) -> Option<f64> + 'a {
move |ui: &mut egui::Ui| {
if let Some(v) = widgets::plain_field(ui, "a", a.get(), "", 0, 80.0) {
a.set(v);
}
let first = ui.min_rect();
ui.add_space(40.0);
let top = ui.cursor().top();
let v = widgets::plain_field(ui, "b", b.get(), "", 0, 80.0).inspect(|&v| b.set(v));
rects.set([first, Rect::from_x_y_ranges(first.x_range(), top..=ui.min_rect().bottom())]);
v
}
}
/// A stream that began on one number field scrolls the panel when the pointer moves onto another
/// field, until the wheel pauses for the stream gap or the pointer is pressed on the other field.
#[test]
fn a_press_on_a_number_field_gives_it_the_running_wheel_stream() {
let mut f = Field::new();
let (a, b, rects) = (Cell::new(10.0), Cell::new(20.0), Cell::new([Rect::NOTHING; 2]));
let draw = two_fields(&a, &b, &rects);
f.frame(vec![], &draw);
let [on_a, on_b] = rects.get().map(|r| r.center());
f.frame(vec![Event::PointerMoved(on_a), notch_down()], &draw);
assert_eq!(a.get(), 9.0, "the stream began on a");
assert_eq!(f.frame(vec![Event::PointerMoved(on_b), notch_down()], &draw), None, "moved onto b within the stream");
let press = |pressed| Event::PointerButton { pos: on_b, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
f.frame(vec![press(true), press(false)], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(19.0), "pressed on b");
assert_eq!(a.get(), 9.0);
// A press on the field that holds the stream keeps it there.
f.frame(vec![press(true), press(false)], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), Some(18.0));
}
/// A window or overlay that comes and goes over a still pointer, with no input while it is up,
/// leaves the number field under it waiting for the pointer to move onto it again.
#[test]
fn a_number_field_covered_by_an_overlay_waits_for_a_pointer_move() {
let mut f = Field::new();
let value = Cell::new(10.0);
let covered = Cell::new(false);
let draw = |ui: &mut egui::Ui| {
let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v));
if covered.get() {
let cover = ui.min_rect().expand(5.0);
egui::Area::new(egui::Id::unique("numfields overlay")).order(egui::Order::Foreground).fixed_pos(cover.min).show(ui.ctx(), |ui| {
ui.allocate_exact_size(cover.size(), egui::Sense::click());
});
}
v
};
f.frame(vec![], &draw);
let at = f.rect.get().center();
f.frame(vec![Event::PointerMoved(at)], &draw);
covered.set(true);
f.frame(vec![], &draw);
f.frame(vec![], &draw);
covered.set(false);
f.frame(vec![], &draw);
f.frame(vec![], &draw);
assert_eq!(f.frame(vec![notch_down()], &draw), None, "the overlay covered the field");
f.time += crate::widgets::WHEEL_STREAM_GAP;
assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0));
}
/// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame.
#[test]
fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() {
let mut f = Field::new();
let arrows = Cell::new(false);
let draw = |ui: &mut egui::Ui| {
let v = widgets::plain_field(ui, "f", 1.123, "", 3, 80.0);
arrows.set(ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::ArrowUp, .. }))));
v
};
f.frame(vec![], &draw);
let at = f.rect.get().center();
assert_eq!(f.frame(vec![Event::PointerMoved(at), key(Key::ArrowUp, Modifiers::NONE)], &draw), None);
assert!(arrows.get());
let mut events = wheel(at, egui::MouseWheelUnit::Line, 1.0, Modifiers::NONE);
events.push(key(Key::ArrowUp, Modifiers::NONE));
assert_eq!(f.frame(events, &draw), Some(2.0), "existing next-multiple rounding, wheel only");
assert!(arrows.get(), "hover does not consume arrows");
assert!(!f.ctx.memory(|m| m.has_focus(f.id.get())));
}
/// Hover steps stop at both bounds, leave mixed values blank, and never edit disabled fields.
#[test]
fn hover_wheel_preserves_limits_mixed_values_and_disabled_fields() {
for (value, enabled, dy, want) in [
(Some(0.5), true, -1.0, Some(0.0)),
(Some(0.0), true, -1.0, None),
(Some(9.5), true, 1.0, Some(10.0)),
(Some(10.0), true, -1.0, Some(9.0)),
(None, true, -1.0, None),
(Some(10.0), false, -1.0, None),
] {
let mut f = Field::new();
let draw = |ui: &mut egui::Ui| ui.add_enabled_ui(enabled, |ui| widgets::range_field(ui, "f", value, 0.0..=10.0, "", 3, 80.0)).inner;
f.frame(vec![], &draw);
assert_eq!(f.frame(wheel(f.rect.get().center(), egui::MouseWheelUnit::Line, dy, Modifiers::NONE), &draw), want);
}
}
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");
}
/// #1326: a pointer movement well under egui's click tolerance must keep the caret too; only a
/// press released within a point of where it went down selects all.
#[test]
fn a_sub_threshold_movement_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();
let nudge = egui::vec2(1.5, 0.0);
f.frame(vec![Event::PointerMoved(at + nudge)], &draw);
f.frame(vec![pointer_button(at + nudge, false)], &draw);
assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a 1.5 px movement is a drag, not a tap (#1326)");
assert_eq!(f.selection(), pressed, "the movement keeps the caret where the press landed");
}
/// 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");
}
/// A pen or trackpad tap often moves a pixel between press and release: it is still a tap.
#[test]
fn a_tap_with_a_pixel_of_jitter_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);
let jitter = egui::vec2(0.7, -0.6);
f.frame(vec![Event::PointerMoved(at + jitter)], &draw);
f.frame(vec![pointer_button(at + jitter, false)], &draw);
assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a jittery 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));
}