1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00
Files
egui/crates/egui_kittest/tests/regression_tests.rs
Lucas Meurer 33c85bd62a Untangle logic-only frames: one frame entry point, one output type
Same fixes as the previous commits (run no egui pass at all when
nothing will be shown), but the mechanisms are shared instead of
duplicated:

* `Context::run_frame(input, show_ui, f)` is the one entry point for
  integrations: one `FramePhase::Logic` (always, outside any pass),
  then one `FramePhase::Ui` per pass (none when `show_ui` is false).
  `Context::run_logic` is now a thin wrapper around it, and the
  logic-outside-of-pass sequencing lives in egui, not in each backend.

* egui itself buffers the input of pass-less frames
  (`ViewportState::pending_raw_input`) and prepends it to the next
  pass. This replaces `EpiIntegration::pending_raw_input` and the web
  backend's `input.raw.append`, so an integration cannot lose input.

* `FullOutput` is now `{ platform_output, viewport_commands,
  pass_output: Option<PassOutput> }`, and `LogicOutput` is gone.
  One-shot viewport commands (imperative) are separated from
  `ViewportOutput` (which viewports should exist - declarative), so
  each backend has exactly one command-handling path, shared by frames
  with and without a pass. `pass_output: None` encodes "no pass ran:
  paint nothing, leave the viewports alone" in the type.

* The glow/wgpu `!show_ui` early-return blocks are gone: a hidden root
  viewport flows through the same tail as a visible one, with the
  paint and viewport-structure steps gated on `pass_output`.

Behavioral fixes that fall out:

* `App::logic` now sees the current window state in visible frames
  too (it was one frame stale outside the hidden path).
* Auto-save keeps working while a window is minimized or occluded.
* Commands sent to a freshly created viewport apply in the same frame.
* The Wayland resize workaround now also covers commands from
  pass-less frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:09:17 +02:00

818 lines
28 KiB
Rust

use egui::accesskit::{self, Role};
use egui::{
Align2, Button, ComboBox, FontId, Image, Label, Modifiers, Popup, Pos2, Rect, Stroke,
StrokeKind, Vec2, Widget as _, Window,
};
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
use egui_kittest::SnapshotResults;
use egui_kittest::{Harness, kittest::Queryable as _};
#[test]
pub fn focus_should_skip_over_disabled_buttons() {
let mut harness = Harness::new_ui(|ui| {
ui.add(Button::new("Button 1"));
ui.add_enabled(false, Button::new("Button Disabled"));
ui.add(Button::new("Button 3"));
});
harness.key_press(egui::Key::Tab);
harness.run();
let button_1 = harness.get_by_label("Button 1");
assert!(button_1.is_focused());
harness.key_press(egui::Key::Tab);
harness.run();
let button_3 = harness.get_by_label("Button 3");
assert!(button_3.is_focused());
harness.key_press(egui::Key::Tab);
harness.run();
let button_1 = harness.get_by_label("Button 1");
assert!(button_1.is_focused());
}
#[test]
pub fn focus_should_skip_over_disabled_drag_values() {
let mut value_1: u16 = 1;
let mut value_2: u16 = 2;
let mut value_3: u16 = 3;
let mut harness = Harness::new_ui(|ui| {
ui.add(egui::DragValue::new(&mut value_1));
ui.add_enabled(false, egui::DragValue::new(&mut value_2));
ui.add(egui::DragValue::new(&mut value_3));
});
harness.key_press(egui::Key::Tab);
harness.run();
let drag_value_1 = harness.get_by(|node| node.numeric_value() == Some(1.0));
assert!(drag_value_1.is_focused());
harness.key_press(egui::Key::Tab);
harness.run();
let drag_value_3 = harness.get_by(|node| node.numeric_value() == Some(3.0));
assert!(drag_value_3.is_focused());
}
#[test]
fn image_failed() {
let mut harness = Harness::new_ui(|ui| {
Image::new("file://invalid/path")
.alt_text("I have an alt text")
.max_size(Vec2::new(100.0, 100.0))
.ui(ui);
});
harness.run();
harness.fit_contents();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
harness.snapshot("image_snapshots");
}
#[test]
fn test_combobox() {
let items = ["Item 1", "Item 2", "Item 3"];
let mut harness = Harness::builder()
.with_size(Vec2::new(300.0, 200.0))
.build_ui_state(
|ui, selected| {
ComboBox::new("combobox", "Select Something").show_index(
ui,
selected,
items.len(),
|idx| *items.get(idx).expect("Invalid index"),
);
},
0,
);
harness.run();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
let mut results = SnapshotResults::new();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
results.add(harness.try_snapshot("combobox_closed"));
let combobox = harness.get_by_role_and_label(Role::ComboBox, "Select Something");
combobox.click();
harness.run();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
results.add(harness.try_snapshot("combobox_opened"));
let item_2 = harness.get_by_role_and_label(Role::Button, "Item 2");
item_2.click();
harness.run();
assert_eq!(harness.state(), &1);
// Popup should be closed now
assert!(harness.query_by_label("Item 2").is_none());
}
/// `https://github.com/emilk/egui/issues/7065`
#[test]
pub fn slider_should_move_with_fixed_decimals() {
let mut value: f32 = 1.0;
let mut harness = Harness::new_ui(|ui| {
// Movement on arrow-key is relative to slider width; make the slider wide so the movement becomes small.
ui.spacing_mut().slider_width = 2000.0;
ui.add(egui::Slider::new(&mut value, 0.1..=10.0).fixed_decimals(2));
});
harness.key_press(egui::Key::Tab);
harness.run();
let actual_slider = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(actual_slider.value(), Some("1.00".to_owned()));
harness.key_press(egui::Key::ArrowRight);
harness.run();
let actual_slider = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(actual_slider.value(), Some("1.01".to_owned()));
harness.key_press(egui::Key::ArrowRight);
harness.run();
let actual_slider = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(actual_slider.value(), Some("1.02".to_owned()));
harness.key_press(egui::Key::ArrowLeft);
harness.run();
let actual_slider = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(actual_slider.value(), Some("1.01".to_owned()));
harness.key_press(egui::Key::ArrowLeft);
harness.run();
let actual_slider = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(actual_slider.value(), Some("1.00".to_owned()));
}
#[test]
pub fn override_text_color_affects_interactive_widgets() {
use egui::{Color32, RichText};
let mut harness = Harness::new_ui(|ui| {
_ = ui.button("normal");
_ = ui.checkbox(&mut true, "normal");
_ = ui.radio(true, "normal");
ui.visuals_mut().widgets.inactive.fg_stroke.color = Color32::RED;
_ = ui.button("red");
_ = ui.checkbox(&mut true, "red");
_ = ui.radio(true, "red");
// override_text_color takes precedence over `WidgetVisuals`, as it docstring claims
ui.visuals_mut().override_text_color = Some(Color32::GREEN);
_ = ui.button("green");
_ = ui.checkbox(&mut true, "green");
_ = ui.radio(true, "green");
// Setting the color explicitly with `RichText` overrides style
_ = ui.button(RichText::new("blue").color(Color32::BLUE));
_ = ui.checkbox(&mut true, RichText::new("blue").color(Color32::BLUE));
_ = ui.radio(true, RichText::new("blue").color(Color32::BLUE));
});
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
let mut results = SnapshotResults::new();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
results.add(harness.try_snapshot("override_text_color_interactive"));
}
/// <https://github.com/rerun-io/rerun/issues/11301>
#[test]
pub fn menus_should_close_even_if_submenu_disappears() {
const OTHER_BUTTON: &str = "Other button";
const MENU_BUTTON: &str = "Menu";
const SUB_MENU_BUTTON: &str = "Always here";
const TOGGLEABLE_SUB_MENU_BUTTON: &str = "Maybe here";
const INSIDE_SUB_MENU_BUTTON: &str = "Inside submenu";
for frame_delay in (0..3).rev() {
let mut harness = Harness::builder().build_ui_state(
|ui, state| {
let _ = ui.button(OTHER_BUTTON).clicked();
let response = ui.button(MENU_BUTTON);
Popup::menu(&response).show(|ui| {
let _ = ui.button(SUB_MENU_BUTTON);
if *state {
ui.menu_button(TOGGLEABLE_SUB_MENU_BUTTON, |ui| {
let _ = ui.button(INSIDE_SUB_MENU_BUTTON);
});
}
});
},
true,
);
// Open the main menu
harness.get_by_label(MENU_BUTTON).click();
harness.run();
// Open the sub menu
harness
.get_by_label_contains(TOGGLEABLE_SUB_MENU_BUTTON)
.hover();
harness.run();
// Have we opened the submenu successfully?
harness.get_by_label(INSIDE_SUB_MENU_BUTTON).hover();
harness.run();
// We click manually, since we want to precisely time that the sub menu disappears when the
// button is released
let center = harness.get_by_label(OTHER_BUTTON).rect().center();
harness.input_mut().events.push(egui::Event::PointerButton {
pos: center,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Modifiers::default(),
});
harness.step();
// Yank the sub menu from under the pointer
*harness.state_mut() = false;
// See if we handle it with or without a frame delay
harness.run_steps(frame_delay);
// Actually close the menu by clicking somewhere outside
harness.input_mut().events.push(egui::Event::PointerButton {
pos: center,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Modifiers::default(),
});
harness.run();
assert!(
harness.query_by_label_contains(SUB_MENU_BUTTON).is_none(),
"Menu failed to close. frame_delay = {frame_delay}"
);
}
}
fn keyboard_submenu_harness() -> Harness<'static, bool> {
Harness::builder()
.with_size(Vec2::new(400.0, 240.0))
.build_ui_state(
|ui, checked| {
egui::Panel::top("menu_bar").show(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("X", |ui| {
ui.menu_button("Y", |ui| {
ui.checkbox(checked, "Goal");
});
});
});
});
},
false,
)
}
#[test]
pub fn keyboard_should_open_nested_submenu() {
let mut harness = keyboard_submenu_harness();
harness.get_by_label("X").focus();
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
harness.get_by_label_contains("Y").focus();
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
assert!(
harness.query_by_label("Goal").is_some(),
"Expected nested submenu to open via keyboard"
);
}
#[test]
pub fn keyboard_should_close_nested_submenu_with_second_enter() {
let mut harness = keyboard_submenu_harness();
harness.get_by_label("X").focus();
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
harness.get_by_label_contains("Y").focus();
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
assert!(
harness.query_by_label("Goal").is_some(),
"Expected nested submenu to open before close attempt"
);
harness.get_by_label_contains("Y").focus();
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
assert!(
harness.query_by_label("Goal").is_none(),
"Expected nested submenu to close when pressing Enter again"
);
}
/// Regression test for a bug in `horizontal_wrapped` layouts where text wraps but does not
/// move to the next line, causing overlapping text.
///
/// Sweeps the available width from 200 down to 50 (one frame per width) and asserts that no
/// two `TextRun` accesskit nodes (one per laid-out row) have overlapping bounds, and that
/// all accesskit text runs and painted text shapes stay within the `horizontal_wrapped` rect.
#[test]
pub fn horizontal_wrapped_text_should_not_overlap() {
struct State {
width: f32,
rect: egui::Rect,
}
let mut harness = Harness::builder()
.with_size(Vec2::new(300.0, 400.0))
.build_ui_state(
|ui, state: &mut State| {
ui.set_width(state.width);
state.rect = egui::Frame::popup(ui.style())
.show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.set_width(ui.available_width());
for i in 0..20 {
ui.label(format!("Hello{i}"));
}
})
.response
.rect
})
.inner;
},
State {
width: 200.0,
rect: Rect::NAN,
},
);
let min_width = 50.0;
loop {
let width = harness.state().width - 1.0;
if width < min_width {
break;
}
harness.state_mut().width = width;
harness.step();
let container_rect = harness.state().rect.expand(1.0);
let runs: Vec<_> = harness
.query_all_by_role(accesskit::Role::TextRun)
.map(|node| (node.rect(), node.value().unwrap_or_default()))
.collect();
for (rect, text) in &runs {
assert!(
container_rect.contains_rect(*rect),
"TextRun rect at available width = {width} is outside horizontal_wrapped rect: \
{text:?} {rect:?} outside {container_rect:?}"
);
}
for clipped in &harness.output().expect_pass().shapes {
if let egui::epaint::Shape::Text(text_shape) = &clipped.shape {
let shape_rect = text_shape.visual_bounding_rect();
assert!(
container_rect.contains_rect(shape_rect),
"TextShape rect at available width = {width} is outside horizontal_wrapped rect: \
{:?} {shape_rect:?} outside {container_rect:?}",
text_shape.galley.text()
);
}
}
for i in 0..runs.len() {
for j in (i + 1)..runs.len() {
let (a, ta) = &runs[i];
let (b, tb) = &runs[j];
let inter = a.intersect(*b);
// Allow tiny floating-point slop for rects that just touch.
let overlaps = inter.width() > 0.5 && inter.height() > 0.5;
assert!(
!overlaps,
"TextRun rects overlap at available width = {width}: \
{ta:?} {a:?} vs {tb:?} {b:?} \
(overlap = {}x{})",
inter.width(),
inter.height()
);
}
}
}
}
#[test]
pub fn pointer_click_on_open_submenu_button_should_not_close_it() {
let mut harness = keyboard_submenu_harness();
harness.get_by_label("X").click();
harness.run();
harness.get_by_label_contains("Y").click();
harness.run();
assert!(
harness.query_by_label("Goal").is_some(),
"Expected submenu to remain open after pointer click on its button"
);
harness.get_by_label_contains("Y").click();
harness.run();
assert!(
harness.query_by_label("Goal").is_some(),
"Expected submenu to remain open on repeated pointer click"
);
}
/// This test checks if we correctly handle wrapping content proceeding non-wrapping content
/// during window resize. When the window is resized past non-wrapping content, the wrapping content
/// above should stay at that non wrapping width and not wrap any further.
#[test]
fn window_resize_wraps_to_content_min_width() {
let wrap_text = "This label should wrap as the window is narrowed. \
It should not shrink smaller than the bottom labels width though.";
let non_wrap_text = "This is the bottom non-wrapping label which is wider.";
let window_title = "resize_wrap_regression";
let mut harness = Harness::builder()
.with_size(Vec2::new(800.0, 600.0))
.build_ui(move |ui| {
Window::new(window_title)
.default_pos([20.0, 20.0])
.default_size([400.0, 200.0])
.show(ui.ctx(), |ui| {
ui.add(Label::new(wrap_text).wrap());
ui.add(Label::new(non_wrap_text).extend());
});
});
harness.run();
let window_rect = harness
.get_by_role_and_label(Role::Window, window_title)
.rect();
// Drag the right edge inward, well past the non-wrapping label's natural
// width, so the non-wrapping label pins the window's minimum width while
// the wrapping label would (without the fix) keep shrinking.
let grab = Pos2::new(window_rect.right(), window_rect.center().y);
let target = Pos2::new(window_rect.left() + 80.0, window_rect.center().y);
harness.drag_at(grab);
harness.run();
harness.hover_at(target);
harness.run();
let wrap_width = harness.get_by_label(wrap_text).rect().width();
let non_wrap_width = harness.get_by_label(non_wrap_text).rect().width();
// Wrapped text won't perfectly fill the available width — each line ends
// wherever the next word stops fitting. The tolerance absorbs that
// word-break slack while still catching the bug, where the wrap label
// would be substantially narrower than the non-wrapping label.
assert!(
non_wrap_width - wrap_width < 40.0,
"wrapping label width ({wrap_width}) is much narrower than the \
non-wrapping label width ({non_wrap_width}) after shrinking the \
window past the non-wrapping label's natural width"
);
}
/// A `Grid` gives its last column all the available width, so a width-filling widget in it
/// (here a `Separator`) makes the grid remember a column width that is really just
/// "however wide the window happened to be".
///
/// When `Resize` then measures the minimum content width in a sizing pass, that remembered
/// width must not be reported as the minimum — otherwise the window can be widened but
/// never shrunk again.
#[test]
fn window_with_grid_can_shrink_after_being_widened() {
let window_title = "grid_shrink_regression";
let mut harness = Harness::builder()
.with_size(Vec2::new(800.0, 600.0))
.build_ui(move |ui| {
Window::new(window_title)
.default_pos([20.0, 20.0])
.default_width(280.0)
.show(ui.ctx(), |ui| {
egui::Grid::new("grid").num_columns(2).show(ui, |ui| {
ui.label("Separator");
ui.separator(); // Fills the available width
ui.end_row();
});
});
});
harness.run();
let drag_right_edge = |harness: &mut Harness<'_>, dx: f32| {
let rect = harness
.get_by_role_and_label(Role::Window, window_title)
.rect();
let grab = Pos2::new(rect.right(), rect.center().y);
harness.hover_at(grab);
harness.run();
harness.drag_at(grab);
harness.run();
harness.hover_at(grab + Vec2::new(dx, 0.0));
harness.run();
harness.drop_at(grab + Vec2::new(dx, 0.0));
harness.run();
harness
.get_by_role_and_label(Role::Window, window_title)
.rect()
.width()
};
let widened = drag_right_edge(&mut harness, 300.0);
let shrunk = drag_right_edge(&mut harness, -300.0);
assert!(
shrunk < widened - 200.0,
"window could not be shrunk again after being widened: \
widened to {widened}, then only shrunk to {shrunk}"
);
}
/// Ensure that the size passed to window is actually treated as outer size (including
/// margins and borders).
#[test]
fn window_fixed_size_is_outer_size() {
use egui::{Color32, Frame, Margin, Pos2, Shape};
let outer_pos = Pos2::new(50.0, 50.0);
let outer_size = Vec2::new(300.0, 200.0);
let outer_margin = Margin::same(10);
let expected_rect = Rect::from_min_size(outer_pos, outer_size);
let mut harness = Harness::builder()
.with_size(Vec2::new(800.0, 600.0))
.build_ui(move |ui| {
let frame = Frame::window(ui.style()).outer_margin(outer_margin);
Window::new("size_test")
.frame(frame)
.fixed_pos(outer_pos)
.fixed_size(outer_size)
.show(ui.ctx(), |ui| {
// Fill the available space so `Resize` doesn't auto-shrink the window
// below the requested fixed size.
ui.allocate_space(ui.available_size());
});
// Paint a debug rect on top of everything that marks the expected outer
// window rect. In the snapshot this should line up exactly with the
// painted window frame.
let painter = ui.ctx().debug_painter();
painter.rect_stroke(
expected_rect,
0.0,
Stroke::new(2.0, Color32::RED),
StrokeKind::Outside,
);
painter.text(
expected_rect.left_top() + Vec2::new(0.0, -4.0),
Align2::LEFT_BOTTOM,
"should perfectly match the outer window size/position",
FontId::default(),
Color32::RED,
);
// Also paint the expected *visible frame* rect (outer rect shrunk by the
// frame's outer_margin). In the snapshot this should line up exactly with
// the painted window frame.
let expected_frame_rect = expected_rect - outer_margin;
painter.debug_rect(
expected_frame_rect,
Color32::GREEN,
"should perfectly match the painted window frame",
);
});
harness.run();
#[cfg(all(feature = "wgpu", feature = "snapshot"))]
harness.snapshot("window_outer_size");
fn collect_filled_rect_sizes(shape: &Shape, out: &mut Vec<Vec2>) {
match shape {
// Skip stroke-only rects (fill == TRANSPARENT), so the debug overlay
// doesn't trivially satisfy the size check.
Shape::Rect(r) if r.fill != Color32::TRANSPARENT => out.push(r.rect.size()),
Shape::Vec(v) => v.iter().for_each(|s| collect_filled_rect_sizes(s, out)),
_ => {}
}
}
let mut sizes = Vec::new();
for clipped in &harness.output().expect_pass().shapes {
collect_filled_rect_sizes(&clipped.shape, &mut sizes);
}
// The shape will have the inner size
let painted_size = outer_size - outer_margin.sum();
let found = sizes
.iter()
.any(|s| (s.x - painted_size.x).abs() < 0.5 && (s.y - painted_size.y).abs() < 0.5);
assert!(
found,
"expected a filled RectShape with size {painted_size:?} (outer size {outer_size:?} \
minus outer margin {outer_margin:?}) in the paint output, but no painted rect matched. \
Found filled-rect sizes: {sizes:?}"
);
}
/// Regression test for <https://github.com/emilk/egui/issues/8055>:
/// when content overflows a `Panel`, the returned response (and the panel's
/// stored size, resize handle, and separator) must stay clamped to the panel's
/// allowed size — they used to inherit the overflowing content rect.
#[test]
fn panel_rect_clamped_when_content_overflows() {
use std::cell::RefCell;
let side_panel_width = 100.0_f32;
let top_panel_height = 80.0_f32;
let side_response: RefCell<Option<egui::Response>> = RefCell::new(None);
let top_response: RefCell<Option<egui::Response>> = RefCell::new(None);
let mut harness = Harness::builder()
.with_size(Vec2::new(400.0, 300.0))
.build_ui(|ui| {
let r = egui::Panel::left("left_panel")
.exact_size(side_panel_width)
.show(ui, |ui| {
// Allocate way more than the panel — would overflow without the clamp.
ui.allocate_space(Vec2::new(1000.0, 10.0));
});
*side_response.borrow_mut() = Some(r.response);
let r = egui::Panel::top("top_panel")
.exact_size(top_panel_height)
.show(ui, |ui| {
ui.allocate_space(Vec2::new(10.0, 1000.0));
});
*top_response.borrow_mut() = Some(r.response);
});
harness.run();
let sr = side_response.borrow();
let sr = sr.as_ref().expect("left panel response was captured");
assert!(
sr.rect.width() <= side_panel_width + 1.0,
"left panel rect.width()={} exceeded the configured panel width {side_panel_width}",
sr.rect.width()
);
assert!(
sr.interact_rect.width() <= side_panel_width + 1.0,
"left panel interact_rect.width()={} exceeded the configured panel width {side_panel_width}",
sr.interact_rect.width()
);
let tr = top_response.borrow();
let tr = tr.as_ref().expect("top panel response was captured");
assert!(
tr.rect.height() <= top_panel_height + 1.0,
"top panel rect.height()={} exceeded the configured panel height {top_panel_height}",
tr.rect.height()
);
assert!(
tr.interact_rect.height() <= top_panel_height + 1.0,
"top panel interact_rect.height()={} exceeded the configured panel height {top_panel_height}",
tr.interact_rect.height()
);
}
/// Regression test: when an animated panel slides off-screen (collapsing), the
/// enclosing parent (e.g. a `Window`) must not be grown to include the slid-off
/// portion of the panel.
#[test]
fn collapsing_panel_must_not_grow_enclosing_window() {
use std::cell::RefCell;
let window_rect: RefCell<Option<Rect>> = RefCell::new(None);
let is_expanded: RefCell<bool> = RefCell::new(true);
let mut harness = Harness::builder()
.with_size(Vec2::new(800.0, 600.0))
.build_ui(|ui| {
let resp = egui::Window::new("panels_window")
.vscroll(false)
.show(ui.ctx(), |ui| {
egui::Panel::bottom("bottom_panel")
.resizable(false)
.min_size(60.0)
.show_collapsible(ui, &mut is_expanded.borrow_mut(), |ui| {
ui.label("bottom content");
});
egui::CentralPanel::default().show(ui, |ui| {
ui.label("central");
});
});
if let Some(resp) = resp {
*window_rect.borrow_mut() = Some(resp.response.rect);
}
});
harness.run();
let initial = window_rect.borrow().expect("window rect captured");
// Trigger the collapse animation.
*is_expanded.borrow_mut() = false;
// Step through the animation frames; the window must never grow taller than
// its initial height (slid-off panel portion must not push the window out).
for i in 0..30 {
harness.step();
let r = window_rect.borrow().expect("window rect captured");
assert!(
r.height() <= initial.height() + 0.5,
"frame {i}: window grew during panel collapse: initial h={}, now h={}",
initial.height(),
r.height(),
);
}
}
/// The hint text of a `TextEdit` should follow the same alignment as the input
/// text, instead of always being left-top aligned.
///
/// Regression test for <https://github.com/emilk/egui/issues/8309>.
#[test]
pub fn textedit_hint_text_should_follow_text_alignment() {
let mut input = String::new();
let mut harness = Harness::builder()
.with_size(Vec2::new(200.0, 40.0))
.build_ui(|ui| {
ui.add(
egui::TextEdit::singleline(&mut input)
.hint_text("Hint")
.desired_width(200.0)
.horizontal_align(egui::Align::Center),
);
});
harness.run();
let text_edit = harness.get_by_role(accesskit::Role::TextInput);
let edit_rect = text_edit.rect();
// Find the hint text shape (the only text shape while the input is empty).
let hint_shape = harness
.output()
.expect_pass()
.shapes
.iter()
.find_map(|clipped| {
let egui::epaint::Shape::Text(text_shape) = &clipped.shape else {
return None;
};
(text_shape.galley.text() == "Hint").then_some(text_shape)
})
.expect("hint text shape should be painted");
let hint_center_x = hint_shape.pos.x + hint_shape.galley.size().x / 2.0;
let edit_center_x = edit_rect.center().x;
assert!(
(hint_center_x - edit_center_x).abs() < 1.0,
"hint text should be centered in the TextEdit: hint_center_x={hint_center_x}, \
edit_center_x={edit_center_x}, edit_rect={edit_rect:?}",
);
}