From 1da89b36a49c49fa977e3da2bc5eae0e03e03ef2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:19:28 +0000 Subject: [PATCH] refactor(ui-egui): move widgets tests to widgets/tests.rs --- crates/ui-egui/src/widgets.rs | 535 +--------------------------- crates/ui-egui/src/widgets/tests.rs | 532 +++++++++++++++++++++++++++ 2 files changed, 533 insertions(+), 534 deletions(-) create mode 100644 crates/ui-egui/src/widgets/tests.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3a068c834..e080806b1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1349,537 +1349,4 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option| { - h.get_all_by_label("Bold").map(|n| n.rect().center()).min_by(|a, b| a.y.total_cmp(&b.y)).expect("the ribbon has Bold") - }; - let right_click = |h: &mut egui_kittest::Harness<'static, WordApp>, at: egui::Pos2| { - h.event(egui::Event::PointerMoved(at)); - for pressed in [true, false] { - h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); - } - h.run_steps(2); - }; - let at = ribbon_bold(&h); - right_click(&mut h, at); - assert!(h.query_by_label("Add to Quick Access Toolbar").is_some(), "the button's menu opened"); - assert!(h.query_by_label("Show Quick Access Toolbar").is_none(), "nothing extra while the toolbar shows"); - h.key_press(egui::Key::Escape); - h.run_steps(2); - h.state_mut().run("ui.quickAccessToolbar", serde_json::json!({"value": false})).unwrap(); - h.run_steps(2); - let at = ribbon_bold(&h); - right_click(&mut h, at); - h.get_by_label("Show Quick Access Toolbar").click(); - h.run_steps(2); - assert!(h.state().ui.quick_access_toolbar, "Show Quick Access Toolbar brings it back"); - assert!(h.query_by_label("Show Quick Access Toolbar").is_none(), "and closes the menu"); - } - - fn hint_ctx() -> egui::Context { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(false, false)); - ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); - ctx - } - - /// Runs `frames` frames of `build` from time `t0` on 10 fps, with `events` in the first; returns - /// whether a tooltip layer is showing after the last. - fn hint_showing(ctx: &egui::Context, t0: f64, frames: u32, events: Vec, build: &mut dyn FnMut(&mut Ui)) -> bool { - let mut shown = false; - for k in 0..frames { - let input = egui::RawInput { - time: Some(t0 + f64::from(k) * 0.1), - predicted_dt: 0.1, - events: if k == 0 { events.clone() } else { vec![] }, - ..Default::default() - }; - ctx.run_ui(input, |ui| build(ui)).drop_without_applying_deltas(); - shown = ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == egui::Order::Tooltip)); - } - shown - } - - #[test] - fn a_combo_shows_its_hint_on_hover_and_not_before() { - let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); - let ctx = hint_ctx(); - let field = std::cell::Cell::new(Rect::NOTHING); - let mut build = |ui: &mut Ui| { - combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); - field.set(ui.min_rect()); - }; - assert!(!hint_showing(&ctx, 1.0, 3, vec![], &mut build)); - let over = field.get().left_center() + vec2(20.0, 0.0); - assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(over)], &mut build), "no hint over the field"); - // Over the arrow too. - let ctx = hint_ctx(); - let mut build2 = |ui: &mut Ui| { - combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); - }; - hint_showing(&ctx, 1.0, 3, vec![], &mut build2); - let arrow = field.get().right_center() - vec2(6.0, 0.0); - assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(arrow)], &mut build2), "no hint over the arrow"); - } - - #[test] - fn a_disabled_control_still_shows_its_hint_on_hover() { - let ctx = hint_ctx(); - let rect = std::cell::Cell::new(Rect::NOTHING); - let mut build = |ui: &mut Ui| { - ui.add_enabled_ui(false, |ui| { - let resp = ui.allocate_response(vec2(60.0, 22.0), Sense::click()); - show_hint(&resp, "Bold", &["Select text first".to_string()]); - rect.set(resp.rect); - }); - }; - assert!(!hint_showing(&ctx, 1.0, 3, vec![], &mut build)); - let over = rect.get().center(); - assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(over)], &mut build), "no hint over a disabled control"); - } - - #[test] - fn a_focus_hint_gives_way_to_a_disabled_control_under_the_pointer() { - let ctx = hint_ctx(); - let off = std::cell::Cell::new(Rect::NOTHING); - let mut build = |ui: &mut Ui| { - ui.horizontal(|ui| { - let (_, a) = ui.allocate_exact_size(vec2(40.0, 20.0), Sense::click()); - show_hint(&a, "Bold", &[]); - ui.add_enabled_ui(false, |ui| { - let b = ui.allocate_response(vec2(40.0, 20.0), Sense::click()); - show_hint(&b, "Italic", &["Select text first".to_string()]); - off.set(b.rect); - }); - }); - }; - let tips = |ctx: &egui::Context| ctx.memory(|m| m.areas().visible_layer_ids().iter().filter(|l| l.order == egui::Order::Tooltip).count()); - hint_showing(&ctx, 1.0, 2, vec![], &mut build); - let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let over = egui::Event::PointerMoved(off.get().center()); - // Focus the first control, rest the pointer on the disabled one: past the idle time only its reason shows. - hint_showing(&ctx, 2.0, 2, vec![tab, over], &mut build); - assert!(hint_showing(&ctx, 2.2, 40, vec![], &mut build), "the disabled control shows no hint"); - assert_eq!(tips(&ctx), 1, "the focused control's hint is drawn beside the disabled control's"); - } - - #[test] - fn a_combo_field_reached_with_tab_shows_its_hint_after_a_delay() { - let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); - let ctx = hint_ctx(); - let focused = std::cell::Cell::new(false); - let mut build = |ui: &mut Ui| { - combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); - focused.set(ui.memory(|m| m.focused()).is_some()); - }; - hint_showing(&ctx, 1.0, 2, vec![], &mut build); - let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - assert!(!hint_showing(&ctx, 2.0, 2, vec![tab], &mut build)); - assert!(focused.get(), "Tab did not focus the field"); - assert!(hint_showing(&ctx, 2.2, 10, vec![], &mut build), "focused field shows no hint"); - } - - #[test] - fn keyboard_focus_shows_the_hint_after_a_delay_but_not_over_a_menu() { - let ctx = hint_ctx(); - let btn_id = std::cell::Cell::new(None); - let menu = std::cell::Cell::new(false); - let mut build = |ui: &mut Ui| { - let (_, resp) = ui.allocate_exact_size(vec2(40.0, 20.0), Sense::click()); - btn_id.set(Some(resp.id)); - if menu.get() { - egui::Popup::menu(&resp).open_memory(egui::SetOpenCommand::Bool(true)).show(|ui| ui.label("menu")); - } - show_hint(&resp, "Bold", &[]); - }; - hint_showing(&ctx, 1.0, 2, vec![], &mut build); - let Some(id) = btn_id.get() else { return }; - // Tab moves the keyboard focus onto the button, as it does for a user. - let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - // Just focused: no hint yet; after the tooltip delay: shown. - assert!(!hint_showing(&ctx, 2.0, 2, vec![tab], &mut build)); - assert!(ctx.memory(|m| m.has_focus(id)), "Tab did not focus the button"); - assert!(hint_showing(&ctx, 2.2, 10, vec![], &mut build), "focused control shows no hint"); - // A menu open anywhere hides it. - menu.set(true); - assert!(!hint_showing(&ctx, 4.0, 3, vec![], &mut build), "hint drawn over an open menu"); - } - - #[test] - fn tooltip_lines_do_not_repeat_a_shortcut_already_in_the_label() { - assert_eq!(tooltip_lines("Paste (⌘V)", "⌘V", "", None).0, "Paste (⌘V)"); - assert_eq!(tooltip_lines("Paste (Ctrl+V)", "Ctrl+V", "", None).0, "Paste (Ctrl+V)"); - assert_eq!(tooltip_lines("Font (dialog)", "Ctrl+D", "", None).0, "Font (dialog) (Ctrl+D)"); - } - - #[test] - fn tooltip_lines_name_shortcut_group_and_reason() { - let (t, n) = tooltip_lines("Bold", "Ctrl+B", "Home > Font", None); - assert_eq!(t, "Bold (Ctrl+B)"); - assert_eq!(n, vec!["Home > Font".to_string()]); - let (t, n) = tooltip_lines("Header", "", "", Some("Open a document first")); - assert_eq!(t, "Header"); - assert_eq!(n, vec!["Open a document first".to_string()]); - let (_, n) = tooltip_lines("Header", "", "", Some("nothing is selected")); - assert_eq!(n, vec!["Nothing is selected".to_string()]); - // No reason given: the generic line. - let (_, n) = tooltip_lines("Header", "", "", Some("")); - assert_eq!(n, vec!["Not available right now".to_string()]); - } - - #[test] - fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() { - // #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons. - let ctx = egui::Context::default(); - crate::theme::apply(&ctx, crate::theme::Appearance::Light); - let clock = std::cell::Cell::new(0u32); - let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER)); - // One 60 fps frame; returns both buttons' rects and fills (framed, frameless). - let frame = |events: Vec| { - clock.set(clock.get() + 1); - let input = egui::RawInput { time: Some(f64::from(clock.get()) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; - ctx.run_ui(input, |ui| { - let framed = icon_text_button(ui, "comment", "Comments", regular(12.0)); - let frameless = icon_button(ui, "close", "Close"); - let (a, b) = (drawn_state(ui, &framed, true).fill, drawn_state(ui, &frameless, false).fill); - seen.set((framed.rect, frameless.rect, a, b)); - }) - .drop_without_applying_deltas(); - seen.get() - }; - // Half a second of frames, so animations finish; returns the last one. - let settle = || { - for _ in 0..29 { - frame(vec![]); - } - frame(vec![]) - }; - let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; - let w = ctx.global_style().visuals.widgets.clone(); - let t = Tokens::light(); - - let (framed, frameless, idle, idle_bare) = settle(); - assert_eq!(idle, w.inactive.weak_bg_fill); - assert_eq!(idle_bare, Color32::TRANSPARENT); - - // Hover fades in over a few frames rather than jumping, then settles on egui's hovered fill. - let first = frame(vec![egui::Event::PointerMoved(framed.center())]).2; - let between = |c: Color32, a: Color32, b: Color32| a.r().min(b.r()) < c.r() && c.r() < a.r().max(b.r()); - assert!(between(first, idle, w.hovered.weak_bg_fill), "hover should animate: {idle:?} → {first:?} → {:?}", w.hovered.weak_bg_fill); - assert_eq!(settle().2, w.hovered.weak_bg_fill); - // Held down: blends to the pressed (active) fill. - frame(vec![press(framed.center(), true)]); - assert_eq!(settle().2, w.active.weak_bg_fill); - frame(vec![press(framed.center(), false)]); - - // The frameless close button uses the ribbon's hover and pressed tokens; the other fades out. - frame(vec![egui::Event::PointerMoved(frameless.center())]); - let after = settle(); - assert_eq!(after.3, t.hover); - assert_eq!(after.2, w.inactive.weak_bg_fill); - frame(vec![press(frameless.center(), true)]); - assert_eq!(settle().3, t.pressed); - } - - #[test] - fn font_matches_puts_exact_and_prefix_before_substring() { - let items: Vec = ["Arial Black", "Arial", "Courier New", "Times New Roman", "Roman Serif"].map(String::from).to_vec(); - // Case-insensitive; the exact name first, then prefixes, then other substrings. - assert_eq!(font_matches("ARIAL", &items), vec![1, 0]); - assert_eq!(font_matches("roman", &items), vec![4, 3]); - assert_eq!(font_matches(" new ", &items), vec![2, 3]); - assert!(font_matches("zapfino", &items).is_empty()); - assert_eq!(font_matches("", &items), vec![0, 1, 2, 3, 4]); - } - - /// Focuses a fresh combo, types `typed`, presses `keys` (one per frame) and returns its value. - fn type_into_combo(search: bool, items: &[String], typed: &str, keys: &[egui::Key]) -> Option { - type_into(search, items, &[], typed, keys) - } - - /// [`type_into_combo`] for a font box headed by the `recent` families. - fn type_into(search: bool, items: &[String], recent: &[String], typed: &str, keys: &[egui::Key]) -> Option { - let ctx = egui::Context::default(); - let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let mut frames = vec![vec![], vec![egui::Event::Text(typed.into())]]; - frames.extend(keys.iter().map(|&k| vec![key(k)])); - let mut out = None; - for (n, events) in frames.into_iter().enumerate() { - ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - if n == 0 { - ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); - } - let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) }; - out = out.take().or(v); - }) - .drop_without_applying_deltas(); - } - out - } - - #[test] - fn typing_in_the_font_box_finds_the_family() { - // #359: typing "arial" or "roman" picks the installed family; the size box stays literal. - use egui::Key::{ArrowDown, Enter, Escape}; - let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); - assert_eq!(type_into_combo(true, &fonts, "arial", &[Enter]).as_deref(), Some("Arial")); - assert_eq!(type_into_combo(true, &fonts, "roman", &[Enter]).as_deref(), Some("Times New Roman")); - assert_eq!(type_into_combo(true, &fonts, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black")); - // Not installed: applied as typed, as Word allows. - assert_eq!(type_into_combo(true, &fonts, "Zapfino", &[Enter]).as_deref(), Some("Zapfino")); - assert_eq!(type_into_combo(true, &fonts, "arial", &[Escape]), None); - let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); - assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1")); - } - - /// #2068: with nothing typed, the recent families head the list and Enter applies the one - /// highlighted; Down walks past them into All Fonts; a query lists its matches only. - #[test] - fn recent_fonts_head_the_typed_list() { - use egui::Key::{ArrowDown, ArrowUp, Backspace, Enter}; - let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); - let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); - // Typing, then clearing the box, leaves the list open on the recent fonts. Enter alone - // applies nothing the user didn't pick; Down picks the second recent font. - assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, Enter]).as_deref(), Some("")); - assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowUp, Enter]).as_deref(), Some("Georgia")); - assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, Enter]).as_deref(), Some("Courier New")); - // Past the recent entries comes All Fonts; the headings are skipped. - assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowDown, Enter]).as_deref(), Some("Arial")); - // A query lists the matching installed families only. - assert_eq!(type_into(true, &fonts, &recent, "cour", &[Enter]).as_deref(), Some("Courier New")); - assert_eq!(type_into(true, &fonts, &recent, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black")); - // Nothing recent, nothing typed and no key moved the highlight: the text is committed as before. - assert_eq!(type_into(true, &fonts, &[], "x", &[Backspace, Enter]).as_deref(), Some("")); - } - - /// The typed list once held one match and stayed one row tall when the query then matched - /// many: an area keeps its last size unless a changed query sizes it again. - #[test] - fn the_typed_list_grows_when_the_query_matches_more() { - let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); - let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); - let ctx = egui::Context::default(); - let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let erase = || vec![key(egui::Key::Backspace)]; - let frames = vec![vec![], vec![egui::Event::Text("cour".into())], vec![], erase(), erase(), erase(), erase(), vec![]]; - let mut area = None; - let mut heights = Vec::new(); - for (n, events) in frames.into_iter().enumerate() { - ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - if n == 0 { - ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); - } - area = Some(ui.scope_id().with(("combo_search", "f")).with("list")); - font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); - }) - .drop_without_applying_deltas(); - heights.push(area.and_then(|id| egui::AreaState::load(&ctx, id)).and_then(|s| s.size).map(|s| s.y)); - } - let one = heights[2].expect("one match listed"); - let all = heights[7].expect("every font listed"); - assert!(all > 4.0 * one, "one match: {one} tall; two headings, two recent and four fonts: {all}"); - } - - /// The typed list was built invisibly (a sizing pass) on each keystroke that changed its - /// length, so it blinked out for a frame. It is drawn on the keystroke's own frame. - #[test] - fn the_typed_list_shows_on_the_frame_of_the_keystroke() { - fn has_text(shape: &egui::Shape, s: &str) -> bool { - match shape { - egui::Shape::Text(t) => t.galley.text() == s, - egui::Shape::Vec(v) => v.iter().any(|x| has_text(x, s)), - _ => false, - } - } - let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); - let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); - let ctx = egui::Context::default(); - let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - let erase = || vec![key(egui::Key::Backspace)]; - // Each keystroke and a row the list must draw on that frame: one match, the four - // queries on the way back to a cleared box (whose list shows the recent fonts), then two - // matches. The field itself shows only the typed text. - let frames = [ - (vec![egui::Event::Text("cour".into())], "Courier New"), - (erase(), "Courier New"), - (erase(), "Courier New"), - (erase(), "Arial Black"), - (erase(), "Recently Used Fonts"), - (vec![egui::Event::Text("ar".into())], "Arial Black"), - ]; - ctx.run_ui(egui::RawInput::default(), |ui| { - ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); - font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); - }) - .drop_without_applying_deltas(); - for (n, (events, row)) in frames.into_iter().enumerate() { - let out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); - }); - let drawn = out.shapes.iter().any(|c| has_text(&c.shape, row)); - out.drop_without_applying_deltas(); - assert!(drawn, "keystroke {n}: {row} is drawn"); - } - } - - /// Clearing a query with no matches must reveal the recent section, even before the list - /// has ever had a visible viewport. A matching query is the control for a measured viewport. - #[test] - fn clearing_a_font_query_keeps_the_recent_heading_visible() { - use egui::accesskit::Role; - use egui_kittest::kittest::{By, Queryable}; - for query in ["a", "x"] { - // A short list clamps the bad offset to zero and would hide this regression. - let fonts: Vec = (0..24).map(|n| format!("Family {n:02}")).collect(); - let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 600.0)).build_ui(move |ui| { - font_combo_with_recent(ui, "recent_ci_reduced", 150.0, "", &fonts, &recent, None); - }); - h.run_steps(2); - h.get(By::new().role(Role::TextInput)).focus(); - h.step(); - h.event(egui::Event::Text(query.into())); - h.run_steps(2); - assert!(h.query(By::new().role(Role::TextInput).value(query)).is_some()); - assert!(h.query_by_label("Recently Used Fonts").is_none()); - h.key_press(egui::Key::Backspace); - h.run_steps(2); - assert!(h.query(By::new().role(Role::TextInput).value("")).is_some()); - assert!(h.query_by_label("Recently Used Fonts").is_some(), "cleared query {query:?}"); - assert!(h.query_by_label("All Fonts").is_some()); - } - } - - /// The rows of a sectioned font list, and where the keyboard highlight lands among them. - #[test] - fn font_list_sections_skip_their_headings() { - let items: Vec = ["Arial", "Courier New"].map(String::from).to_vec(); - let recent: Vec = ["Georgia"].map(String::from).to_vec(); - fn names<'a>(items: &'a [String], recent: &'a [String], n: usize) -> Vec { - let item = |k: usize| items.get(k); - (0..n) - .map(|k| match section_row(k, recent, &item) { - Some(Row::Heading(h)) => format!("[{h}]"), - Some(Row::Item(i)) => i.clone(), - None => "-".into(), - }) - .collect() - } - assert_eq!(section_rows(1, 2), 5); - assert_eq!(names(&items, &recent, 6), ["[Recently Used Fonts]", "Georgia", "[All Fonts]", "Arial", "Courier New", "-"]); - assert_eq!([highlight_row(0, 1), highlight_row(1, 1), highlight_row(2, 1)], [1, 3, 4]); - // Nothing recent: no headings, the rows are the items. - assert_eq!(section_rows(0, 2), 2); - assert_eq!(names(&items, &[], 3), ["Arial", "Courier New", "-"]); - assert_eq!(highlight_row(1, 0), 1); - } - - /// The field was rounded on all four corners and the arrow on none, so the two looked apart, - /// and their borders stood side by side as a double-width divider. The field is rounded only - /// on the left, the arrow only on the right, and the arrow's box lies under the field by one - /// border width, so a single line divides them; focused or not. - #[test] - fn combo_field_and_arrow_share_one_outline() { - let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); - let ctx = egui::Context::default(); - let cases = [crate::theme::Appearance::Light, crate::theme::Appearance::Dark].into_iter().flat_map(|a| [(a, false), (a, true)]); - for (appearance, focused) in cases { - crate::theme::apply(&ctx, appearance); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - if focused { - ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); - } - combo(ui, "f", 150.0, "12", &sizes, None); - }); - // Painted boxes in paint order: (order, rect, corners, border width). - let boxes: Vec<(usize, Rect, CornerRadius, f32)> = std::mem::take(&mut out.shapes) - .into_iter() - .filter_map(|c| match c.shape { - egui::Shape::Rect(r) => Some((r.rect, r.corner_radius, r.stroke.width)), - _ => None, - }) - .enumerate() - .map(|(i, (rect, c, w))| (i, rect, c, w)) - .collect(); - out.drop_without_applying_deltas(); - let corners: Vec = boxes.iter().map(|b| b.2).collect(); - let field = boxes.iter().find(|b| b.2.nw > 0 && b.2.sw == b.2.nw && b.2.ne == 0 && b.2.se == 0); - let field = field.unwrap_or_else(|| panic!("focused {focused}: no field rounded on the left only in {corners:?}")); - let arrow_corners = CornerRadius { nw: 0, sw: 0, ne: field.2.nw, se: field.2.sw }; - let arrow = boxes.iter().find(|b| b.2 == arrow_corners); - let arrow = arrow.unwrap_or_else(|| panic!("focused {focused}: no arrow rounded on the right only in {corners:?}")); - assert!(!corners.iter().any(|c| c.nw > 0 && c.ne > 0), "focused {focused}: a part is still rounded all round: {corners:?}"); - // One divider as wide as the other borders: the arrow's left border lies exactly under - // the field's right border, and the field is painted over it. - assert!(field.3 > 0.0 && arrow.3 == field.3, "focused {focused}: borders {} and {} differ", field.3, arrow.3); - assert!( - (arrow.1.min.x - (field.1.max.x - field.3)).abs() < 0.01, - "focused {focused}: arrow {:?} and field {:?} borders stand apart", - arrow.1, - field.1 - ); - assert!(arrow.0 < field.0, "focused {focused}: the arrow's box must be painted under the field"); - assert!( - (arrow.1.min.y - field.1.min.y).abs() < 0.01 && (arrow.1.max.y - field.1.max.y).abs() < 0.01, - "focused {focused}: the outlines' tops and bottoms differ" - ); - } - } - - #[test] - fn a_long_combo_list_builds_only_the_rows_in_view() { - // #121: the font menu built (and so loaded the font of) every installed family. - let items: Vec = (0..5000).map(|i| format!("Family {i}")).collect(); - let ctx = egui::Context::default(); - let built = std::cell::RefCell::new(Vec::new()); - let row = |ui: &mut Ui, name: &str| { - built.borrow_mut().push(name.to_string()); - ui.allocate_exact_size(vec2(260.0, COMBO_PREVIEW_ROW_H), Sense::click()).1 - }; - for _ in 0..3 { - built.borrow_mut().clear(); - ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(combo_list(ui, 150.0, "", &items, Some(&row)), None); - }) - .drop_without_applying_deltas(); - let b = built.borrow(); - assert!(!b.is_empty() && b.len() <= 420 / COMBO_PREVIEW_ROW_H as usize + 2, "built {} rows", b.len()); - assert_eq!(b[0], "Family 0"); - } - // Without previews too. - ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(combo_list(ui, 52.0, "8", &items, None), None); - }) - .drop_without_applying_deltas(); - } -} +mod tests; diff --git a/crates/ui-egui/src/widgets/tests.rs b/crates/ui-egui/src/widgets/tests.rs new file mode 100644 index 000000000..f97cb4e7d --- /dev/null +++ b/crates/ui-egui/src/widgets/tests.rs @@ -0,0 +1,532 @@ +use super::*; + +/// #653: a large button's label line is no taller than `LABEL_LINE_H` in the interface font, so +/// two lines (checked at compile time) end above the group title row. +#[test] +fn big_button_label_lines_fit_their_slot() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(false, false)); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + let h = ctx.fonts_mut(|f| f.layout_no_wrap("Page Number Mail Merge gjpqy".into(), crate::theme::regular(11.5), Color32::WHITE).size().y); + assert!(h <= LABEL_LINE_H, "label line is {h} px tall"); +} + +/// #1945: a ribbon button's right-click menu offers Show Quick Access Toolbar only while the +/// toolbar is hidden, and it brings the toolbar back. +#[test] +fn ribbon_menu_offers_show_quick_access_toolbar_only_while_hidden() { + use egui_kittest::kittest::Queryable; + let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + // The ribbon's Bold is the topmost one (the mini toolbar's, when up, sits lower). + let ribbon_bold = |h: &egui_kittest::Harness<'static, WordApp>| { + h.get_all_by_label("Bold").map(|n| n.rect().center()).min_by(|a, b| a.y.total_cmp(&b.y)).expect("the ribbon has Bold") + }; + let right_click = |h: &mut egui_kittest::Harness<'static, WordApp>, at: egui::Pos2| { + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(2); + }; + let at = ribbon_bold(&h); + right_click(&mut h, at); + assert!(h.query_by_label("Add to Quick Access Toolbar").is_some(), "the button's menu opened"); + assert!(h.query_by_label("Show Quick Access Toolbar").is_none(), "nothing extra while the toolbar shows"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + h.state_mut().run("ui.quickAccessToolbar", serde_json::json!({"value": false})).unwrap(); + h.run_steps(2); + let at = ribbon_bold(&h); + right_click(&mut h, at); + h.get_by_label("Show Quick Access Toolbar").click(); + h.run_steps(2); + assert!(h.state().ui.quick_access_toolbar, "Show Quick Access Toolbar brings it back"); + assert!(h.query_by_label("Show Quick Access Toolbar").is_none(), "and closes the menu"); +} + +fn hint_ctx() -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(false, false)); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + ctx +} + +/// Runs `frames` frames of `build` from time `t0` on 10 fps, with `events` in the first; returns +/// whether a tooltip layer is showing after the last. +fn hint_showing(ctx: &egui::Context, t0: f64, frames: u32, events: Vec, build: &mut dyn FnMut(&mut Ui)) -> bool { + let mut shown = false; + for k in 0..frames { + let input = egui::RawInput { + time: Some(t0 + f64::from(k) * 0.1), + predicted_dt: 0.1, + events: if k == 0 { events.clone() } else { vec![] }, + ..Default::default() + }; + ctx.run_ui(input, |ui| build(ui)).drop_without_applying_deltas(); + shown = ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == egui::Order::Tooltip)); + } + shown +} + +#[test] +fn a_combo_shows_its_hint_on_hover_and_not_before() { + let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); + let ctx = hint_ctx(); + let field = std::cell::Cell::new(Rect::NOTHING); + let mut build = |ui: &mut Ui| { + combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); + field.set(ui.min_rect()); + }; + assert!(!hint_showing(&ctx, 1.0, 3, vec![], &mut build)); + let over = field.get().left_center() + vec2(20.0, 0.0); + assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(over)], &mut build), "no hint over the field"); + // Over the arrow too. + let ctx = hint_ctx(); + let mut build2 = |ui: &mut Ui| { + combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); + }; + hint_showing(&ctx, 1.0, 3, vec![], &mut build2); + let arrow = field.get().right_center() - vec2(6.0, 0.0); + assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(arrow)], &mut build2), "no hint over the arrow"); +} + +#[test] +fn a_disabled_control_still_shows_its_hint_on_hover() { + let ctx = hint_ctx(); + let rect = std::cell::Cell::new(Rect::NOTHING); + let mut build = |ui: &mut Ui| { + ui.add_enabled_ui(false, |ui| { + let resp = ui.allocate_response(vec2(60.0, 22.0), Sense::click()); + show_hint(&resp, "Bold", &["Select text first".to_string()]); + rect.set(resp.rect); + }); + }; + assert!(!hint_showing(&ctx, 1.0, 3, vec![], &mut build)); + let over = rect.get().center(); + assert!(hint_showing(&ctx, 2.0, 12, vec![egui::Event::PointerMoved(over)], &mut build), "no hint over a disabled control"); +} + +#[test] +fn a_focus_hint_gives_way_to_a_disabled_control_under_the_pointer() { + let ctx = hint_ctx(); + let off = std::cell::Cell::new(Rect::NOTHING); + let mut build = |ui: &mut Ui| { + ui.horizontal(|ui| { + let (_, a) = ui.allocate_exact_size(vec2(40.0, 20.0), Sense::click()); + show_hint(&a, "Bold", &[]); + ui.add_enabled_ui(false, |ui| { + let b = ui.allocate_response(vec2(40.0, 20.0), Sense::click()); + show_hint(&b, "Italic", &["Select text first".to_string()]); + off.set(b.rect); + }); + }); + }; + let tips = |ctx: &egui::Context| ctx.memory(|m| m.areas().visible_layer_ids().iter().filter(|l| l.order == egui::Order::Tooltip).count()); + hint_showing(&ctx, 1.0, 2, vec![], &mut build); + let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let over = egui::Event::PointerMoved(off.get().center()); + // Focus the first control, rest the pointer on the disabled one: past the idle time only its reason shows. + hint_showing(&ctx, 2.0, 2, vec![tab, over], &mut build); + assert!(hint_showing(&ctx, 2.2, 40, vec![], &mut build), "the disabled control shows no hint"); + assert_eq!(tips(&ctx), 1, "the focused control's hint is drawn beside the disabled control's"); +} + +#[test] +fn a_combo_field_reached_with_tab_shows_its_hint_after_a_delay() { + let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); + let ctx = hint_ctx(); + let focused = std::cell::Cell::new(false); + let mut build = |ui: &mut Ui| { + combo_hint(ui, "f", 150.0, "12", &sizes, None, "Font Size"); + focused.set(ui.memory(|m| m.focused()).is_some()); + }; + hint_showing(&ctx, 1.0, 2, vec![], &mut build); + let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + assert!(!hint_showing(&ctx, 2.0, 2, vec![tab], &mut build)); + assert!(focused.get(), "Tab did not focus the field"); + assert!(hint_showing(&ctx, 2.2, 10, vec![], &mut build), "focused field shows no hint"); +} + +#[test] +fn keyboard_focus_shows_the_hint_after_a_delay_but_not_over_a_menu() { + let ctx = hint_ctx(); + let btn_id = std::cell::Cell::new(None); + let menu = std::cell::Cell::new(false); + let mut build = |ui: &mut Ui| { + let (_, resp) = ui.allocate_exact_size(vec2(40.0, 20.0), Sense::click()); + btn_id.set(Some(resp.id)); + if menu.get() { + egui::Popup::menu(&resp).open_memory(egui::SetOpenCommand::Bool(true)).show(|ui| ui.label("menu")); + } + show_hint(&resp, "Bold", &[]); + }; + hint_showing(&ctx, 1.0, 2, vec![], &mut build); + let Some(id) = btn_id.get() else { return }; + // Tab moves the keyboard focus onto the button, as it does for a user. + let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + // Just focused: no hint yet; after the tooltip delay: shown. + assert!(!hint_showing(&ctx, 2.0, 2, vec![tab], &mut build)); + assert!(ctx.memory(|m| m.has_focus(id)), "Tab did not focus the button"); + assert!(hint_showing(&ctx, 2.2, 10, vec![], &mut build), "focused control shows no hint"); + // A menu open anywhere hides it. + menu.set(true); + assert!(!hint_showing(&ctx, 4.0, 3, vec![], &mut build), "hint drawn over an open menu"); +} + +#[test] +fn tooltip_lines_do_not_repeat_a_shortcut_already_in_the_label() { + assert_eq!(tooltip_lines("Paste (⌘V)", "⌘V", "", None).0, "Paste (⌘V)"); + assert_eq!(tooltip_lines("Paste (Ctrl+V)", "Ctrl+V", "", None).0, "Paste (Ctrl+V)"); + assert_eq!(tooltip_lines("Font (dialog)", "Ctrl+D", "", None).0, "Font (dialog) (Ctrl+D)"); +} + +#[test] +fn tooltip_lines_name_shortcut_group_and_reason() { + let (t, n) = tooltip_lines("Bold", "Ctrl+B", "Home > Font", None); + assert_eq!(t, "Bold (Ctrl+B)"); + assert_eq!(n, vec!["Home > Font".to_string()]); + let (t, n) = tooltip_lines("Header", "", "", Some("Open a document first")); + assert_eq!(t, "Header"); + assert_eq!(n, vec!["Open a document first".to_string()]); + let (_, n) = tooltip_lines("Header", "", "", Some("nothing is selected")); + assert_eq!(n, vec!["Nothing is selected".to_string()]); + // No reason given: the generic line. + let (_, n) = tooltip_lines("Header", "", "", Some("")); + assert_eq!(n, vec!["Not available right now".to_string()]); +} + +#[test] +fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() { + // #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons. + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, crate::theme::Appearance::Light); + let clock = std::cell::Cell::new(0u32); + let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER)); + // One 60 fps frame; returns both buttons' rects and fills (framed, frameless). + let frame = |events: Vec| { + clock.set(clock.get() + 1); + let input = egui::RawInput { time: Some(f64::from(clock.get()) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + ctx.run_ui(input, |ui| { + let framed = icon_text_button(ui, "comment", "Comments", regular(12.0)); + let frameless = icon_button(ui, "close", "Close"); + let (a, b) = (drawn_state(ui, &framed, true).fill, drawn_state(ui, &frameless, false).fill); + seen.set((framed.rect, frameless.rect, a, b)); + }) + .drop_without_applying_deltas(); + seen.get() + }; + // Half a second of frames, so animations finish; returns the last one. + let settle = || { + for _ in 0..29 { + frame(vec![]); + } + frame(vec![]) + }; + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let w = ctx.global_style().visuals.widgets.clone(); + let t = Tokens::light(); + + let (framed, frameless, idle, idle_bare) = settle(); + assert_eq!(idle, w.inactive.weak_bg_fill); + assert_eq!(idle_bare, Color32::TRANSPARENT); + + // Hover fades in over a few frames rather than jumping, then settles on egui's hovered fill. + let first = frame(vec![egui::Event::PointerMoved(framed.center())]).2; + let between = |c: Color32, a: Color32, b: Color32| a.r().min(b.r()) < c.r() && c.r() < a.r().max(b.r()); + assert!(between(first, idle, w.hovered.weak_bg_fill), "hover should animate: {idle:?} → {first:?} → {:?}", w.hovered.weak_bg_fill); + assert_eq!(settle().2, w.hovered.weak_bg_fill); + // Held down: blends to the pressed (active) fill. + frame(vec![press(framed.center(), true)]); + assert_eq!(settle().2, w.active.weak_bg_fill); + frame(vec![press(framed.center(), false)]); + + // The frameless close button uses the ribbon's hover and pressed tokens; the other fades out. + frame(vec![egui::Event::PointerMoved(frameless.center())]); + let after = settle(); + assert_eq!(after.3, t.hover); + assert_eq!(after.2, w.inactive.weak_bg_fill); + frame(vec![press(frameless.center(), true)]); + assert_eq!(settle().3, t.pressed); +} + +#[test] +fn font_matches_puts_exact_and_prefix_before_substring() { + let items: Vec = ["Arial Black", "Arial", "Courier New", "Times New Roman", "Roman Serif"].map(String::from).to_vec(); + // Case-insensitive; the exact name first, then prefixes, then other substrings. + assert_eq!(font_matches("ARIAL", &items), vec![1, 0]); + assert_eq!(font_matches("roman", &items), vec![4, 3]); + assert_eq!(font_matches(" new ", &items), vec![2, 3]); + assert!(font_matches("zapfino", &items).is_empty()); + assert_eq!(font_matches("", &items), vec![0, 1, 2, 3, 4]); +} + +/// Focuses a fresh combo, types `typed`, presses `keys` (one per frame) and returns its value. +fn type_into_combo(search: bool, items: &[String], typed: &str, keys: &[egui::Key]) -> Option { + type_into(search, items, &[], typed, keys) +} + +/// [`type_into_combo`] for a font box headed by the `recent` families. +fn type_into(search: bool, items: &[String], recent: &[String], typed: &str, keys: &[egui::Key]) -> Option { + let ctx = egui::Context::default(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let mut frames = vec![vec![], vec![egui::Event::Text(typed.into())]]; + frames.extend(keys.iter().map(|&k| vec![key(k)])); + let mut out = None; + for (n, events) in frames.into_iter().enumerate() { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if n == 0 { + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); + } + let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) }; + out = out.take().or(v); + }) + .drop_without_applying_deltas(); + } + out +} + +#[test] +fn typing_in_the_font_box_finds_the_family() { + // #359: typing "arial" or "roman" picks the installed family; the size box stays literal. + use egui::Key::{ArrowDown, Enter, Escape}; + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + assert_eq!(type_into_combo(true, &fonts, "arial", &[Enter]).as_deref(), Some("Arial")); + assert_eq!(type_into_combo(true, &fonts, "roman", &[Enter]).as_deref(), Some("Times New Roman")); + assert_eq!(type_into_combo(true, &fonts, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black")); + // Not installed: applied as typed, as Word allows. + assert_eq!(type_into_combo(true, &fonts, "Zapfino", &[Enter]).as_deref(), Some("Zapfino")); + assert_eq!(type_into_combo(true, &fonts, "arial", &[Escape]), None); + let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); + assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1")); +} + +/// #2068: with nothing typed, the recent families head the list and Enter applies the one +/// highlighted; Down walks past them into All Fonts; a query lists its matches only. +#[test] +fn recent_fonts_head_the_typed_list() { + use egui::Key::{ArrowDown, ArrowUp, Backspace, Enter}; + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + // Typing, then clearing the box, leaves the list open on the recent fonts. Enter alone + // applies nothing the user didn't pick; Down picks the second recent font. + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, Enter]).as_deref(), Some("")); + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowUp, Enter]).as_deref(), Some("Georgia")); + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, Enter]).as_deref(), Some("Courier New")); + // Past the recent entries comes All Fonts; the headings are skipped. + assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowDown, Enter]).as_deref(), Some("Arial")); + // A query lists the matching installed families only. + assert_eq!(type_into(true, &fonts, &recent, "cour", &[Enter]).as_deref(), Some("Courier New")); + assert_eq!(type_into(true, &fonts, &recent, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black")); + // Nothing recent, nothing typed and no key moved the highlight: the text is committed as before. + assert_eq!(type_into(true, &fonts, &[], "x", &[Backspace, Enter]).as_deref(), Some("")); +} + +/// The typed list once held one match and stayed one row tall when the query then matched +/// many: an area keeps its last size unless a changed query sizes it again. +#[test] +fn the_typed_list_grows_when_the_query_matches_more() { + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let erase = || vec![key(egui::Key::Backspace)]; + let frames = vec![vec![], vec![egui::Event::Text("cour".into())], vec![], erase(), erase(), erase(), erase(), vec![]]; + let mut area = None; + let mut heights = Vec::new(); + for (n, events) in frames.into_iter().enumerate() { + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if n == 0 { + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); + } + area = Some(ui.scope_id().with(("combo_search", "f")).with("list")); + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }) + .drop_without_applying_deltas(); + heights.push(area.and_then(|id| egui::AreaState::load(&ctx, id)).and_then(|s| s.size).map(|s| s.y)); + } + let one = heights[2].expect("one match listed"); + let all = heights[7].expect("every font listed"); + assert!(all > 4.0 * one, "one match: {one} tall; two headings, two recent and four fonts: {all}"); +} + +/// The typed list was built invisibly (a sizing pass) on each keystroke that changed its +/// length, so it blinked out for a frame. It is drawn on the keystroke's own frame. +#[test] +fn the_typed_list_shows_on_the_frame_of_the_keystroke() { + fn has_text(shape: &egui::Shape, s: &str) -> bool { + match shape { + egui::Shape::Text(t) => t.galley.text() == s, + egui::Shape::Vec(v) => v.iter().any(|x| has_text(x, s)), + _ => false, + } + } + let fonts: Vec = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let erase = || vec![key(egui::Key::Backspace)]; + // Each keystroke and a row the list must draw on that frame: one match, the four + // queries on the way back to a cleared box (whose list shows the recent fonts), then two + // matches. The field itself shows only the typed text. + let frames = [ + (vec![egui::Event::Text("cour".into())], "Courier New"), + (erase(), "Courier New"), + (erase(), "Courier New"), + (erase(), "Arial Black"), + (erase(), "Recently Used Fonts"), + (vec![egui::Event::Text("ar".into())], "Arial Black"), + ]; + ctx.run_ui(egui::RawInput::default(), |ui| { + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }) + .drop_without_applying_deltas(); + for (n, (events, row)) in frames.into_iter().enumerate() { + let out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); + }); + let drawn = out.shapes.iter().any(|c| has_text(&c.shape, row)); + out.drop_without_applying_deltas(); + assert!(drawn, "keystroke {n}: {row} is drawn"); + } +} + +/// Clearing a query with no matches must reveal the recent section, even before the list +/// has ever had a visible viewport. A matching query is the control for a measured viewport. +#[test] +fn clearing_a_font_query_keeps_the_recent_heading_visible() { + use egui::accesskit::Role; + use egui_kittest::kittest::{By, Queryable}; + for query in ["a", "x"] { + // A short list clamps the bad offset to zero and would hide this regression. + let fonts: Vec = (0..24).map(|n| format!("Family {n:02}")).collect(); + let recent: Vec = ["Georgia", "Courier New"].map(String::from).to_vec(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 600.0)).build_ui(move |ui| { + font_combo_with_recent(ui, "recent_ci_reduced", 150.0, "", &fonts, &recent, None); + }); + h.run_steps(2); + h.get(By::new().role(Role::TextInput)).focus(); + h.step(); + h.event(egui::Event::Text(query.into())); + h.run_steps(2); + assert!(h.query(By::new().role(Role::TextInput).value(query)).is_some()); + assert!(h.query_by_label("Recently Used Fonts").is_none()); + h.key_press(egui::Key::Backspace); + h.run_steps(2); + assert!(h.query(By::new().role(Role::TextInput).value("")).is_some()); + assert!(h.query_by_label("Recently Used Fonts").is_some(), "cleared query {query:?}"); + assert!(h.query_by_label("All Fonts").is_some()); + } +} + +/// The rows of a sectioned font list, and where the keyboard highlight lands among them. +#[test] +fn font_list_sections_skip_their_headings() { + let items: Vec = ["Arial", "Courier New"].map(String::from).to_vec(); + let recent: Vec = ["Georgia"].map(String::from).to_vec(); + fn names<'a>(items: &'a [String], recent: &'a [String], n: usize) -> Vec { + let item = |k: usize| items.get(k); + (0..n) + .map(|k| match section_row(k, recent, &item) { + Some(Row::Heading(h)) => format!("[{h}]"), + Some(Row::Item(i)) => i.clone(), + None => "-".into(), + }) + .collect() + } + assert_eq!(section_rows(1, 2), 5); + assert_eq!(names(&items, &recent, 6), ["[Recently Used Fonts]", "Georgia", "[All Fonts]", "Arial", "Courier New", "-"]); + assert_eq!([highlight_row(0, 1), highlight_row(1, 1), highlight_row(2, 1)], [1, 3, 4]); + // Nothing recent: no headings, the rows are the items. + assert_eq!(section_rows(0, 2), 2); + assert_eq!(names(&items, &[], 3), ["Arial", "Courier New", "-"]); + assert_eq!(highlight_row(1, 0), 1); +} + +/// The field was rounded on all four corners and the arrow on none, so the two looked apart, +/// and their borders stood side by side as a double-width divider. The field is rounded only +/// on the left, the arrow only on the right, and the arrow's box lies under the field by one +/// border width, so a single line divides them; focused or not. +#[test] +fn combo_field_and_arrow_share_one_outline() { + let sizes: Vec = ["10", "11", "12"].map(String::from).to_vec(); + let ctx = egui::Context::default(); + let cases = [crate::theme::Appearance::Light, crate::theme::Appearance::Dark].into_iter().flat_map(|a| [(a, false), (a, true)]); + for (appearance, focused) in cases { + crate::theme::apply(&ctx, appearance); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + if focused { + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); + } + combo(ui, "f", 150.0, "12", &sizes, None); + }); + // Painted boxes in paint order: (order, rect, corners, border width). + let boxes: Vec<(usize, Rect, CornerRadius, f32)> = std::mem::take(&mut out.shapes) + .into_iter() + .filter_map(|c| match c.shape { + egui::Shape::Rect(r) => Some((r.rect, r.corner_radius, r.stroke.width)), + _ => None, + }) + .enumerate() + .map(|(i, (rect, c, w))| (i, rect, c, w)) + .collect(); + out.drop_without_applying_deltas(); + let corners: Vec = boxes.iter().map(|b| b.2).collect(); + let field = boxes.iter().find(|b| b.2.nw > 0 && b.2.sw == b.2.nw && b.2.ne == 0 && b.2.se == 0); + let field = field.unwrap_or_else(|| panic!("focused {focused}: no field rounded on the left only in {corners:?}")); + let arrow_corners = CornerRadius { nw: 0, sw: 0, ne: field.2.nw, se: field.2.sw }; + let arrow = boxes.iter().find(|b| b.2 == arrow_corners); + let arrow = arrow.unwrap_or_else(|| panic!("focused {focused}: no arrow rounded on the right only in {corners:?}")); + assert!(!corners.iter().any(|c| c.nw > 0 && c.ne > 0), "focused {focused}: a part is still rounded all round: {corners:?}"); + // One divider as wide as the other borders: the arrow's left border lies exactly under + // the field's right border, and the field is painted over it. + assert!(field.3 > 0.0 && arrow.3 == field.3, "focused {focused}: borders {} and {} differ", field.3, arrow.3); + assert!( + (arrow.1.min.x - (field.1.max.x - field.3)).abs() < 0.01, + "focused {focused}: arrow {:?} and field {:?} borders stand apart", + arrow.1, + field.1 + ); + assert!(arrow.0 < field.0, "focused {focused}: the arrow's box must be painted under the field"); + assert!( + (arrow.1.min.y - field.1.min.y).abs() < 0.01 && (arrow.1.max.y - field.1.max.y).abs() < 0.01, + "focused {focused}: the outlines' tops and bottoms differ" + ); + } +} + +#[test] +fn a_long_combo_list_builds_only_the_rows_in_view() { + // #121: the font menu built (and so loaded the font of) every installed family. + let items: Vec = (0..5000).map(|i| format!("Family {i}")).collect(); + let ctx = egui::Context::default(); + let built = std::cell::RefCell::new(Vec::new()); + let row = |ui: &mut Ui, name: &str| { + built.borrow_mut().push(name.to_string()); + ui.allocate_exact_size(vec2(260.0, COMBO_PREVIEW_ROW_H), Sense::click()).1 + }; + for _ in 0..3 { + built.borrow_mut().clear(); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(combo_list(ui, 150.0, "", &items, Some(&row)), None); + }) + .drop_without_applying_deltas(); + let b = built.borrow(); + assert!(!b.is_empty() && b.len() <= 420 / COMBO_PREVIEW_ROW_H as usize + 2, "built {} rows", b.len()); + assert_eq!(b[0], "Family 0"); + } + // Without previews too. + ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(combo_list(ui, 52.0, "8", &items, None), None); + }) + .drop_without_applying_deltas(); +}