1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 12:50:04 -04:00
Files
egui/crates/egui_kittest/tests/regression_tests.rs
Teddy Tennant d802a982ce Don't revert external changes to a focused DragValue (#8403)
* Closes <https://github.com/emilk/egui/issues/8339>
* [x] I have followed the instructions in the PR template

## The bug

While a `DragValue` has focus it is rendered as a `TextEdit`, and the
text being
edited is stored in `Memory::data` between frames. That is needed so
that
half-finished input such as `"1."` or `"-"` isn't thrown away just
because it
doesn't parse to the current value.

The stored text was only discarded when the widget *gained* focus or
when the
widget itself changed the value. If something else changed the value
while the
`DragValue` was focused, the stored text was kept, shown to the user,
and
written back to the value when focus was lost — silently undoing the
external
change:

```rust
ui.add(egui::DragValue::new(&mut self.value));
if ui.button("increment").clicked() {
    self.value += 1;
}
```

Click into the `DragValue` so it has focus, then press "increment": the
value
goes up for one frame and then snaps back. `Slider` shows the same
behaviour,
since it uses a `DragValue` for its value field.

## The fix

Store the value the text belongs to next to the text, and discard the
text when
the value no longer matches it. The remembered value is read back from
the
get/set closure *after* the widget has applied its own edits, so a
change the
widget made itself never looks like an external one — this matters for
values
that can't represent what was typed, e.g. `"12.5"` in a
`DragValue<i32>`.

This keeps the reason the text is stored in the first place intact: as
long as
nothing else touches the value, the text the user is typing is preserved
verbatim.

## Tests

Three tests in `crates/egui_kittest/tests/regression_tests.rs`:

* `drag_value_should_not_revert_external_changes_while_focused` — the
actual
  regression. Fails on `main`:

  ```
---- drag_value_should_not_revert_external_changes_while_focused stdout
----
  assertion `left == right` failed
    left: Some("0")
   right: Some("42")
  ```

and, with the display assertion removed so the test reaches the blur, on
the
  value itself:

  ```
  assertion `left == right` failed
    left: 0
   right: 42
  ```

* `drag_value_should_keep_text_while_typing` and
`drag_value_should_keep_text_the_value_cannot_represent` — guards for
the
behaviour the stored text exists for. Both pass on `main` and after the
fix,
  and both fail if the text is re-read from the value too eagerly.

`cargo test -p egui_kittest` and `cargo test -p egui` pass, as do
`cargo fmt --all --check`, `scripts/lint.py` and
`cargo clippy -p egui -p egui_kittest --all-targets --all-features -- -D
warnings`.

## Not changed

`DragValue` still ignores the stored text when <kbd>Escape</kbd> is
pressed, and
`update_while_editing` still decides when typed text is applied —
neither is
touched here.
2026-08-11 14:04:09 +02:00

914 lines
31 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().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().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 core::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 core::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()
.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:?}",
);
}
/// A focused `DragValue` keeps the text the user is editing in memory.
///
/// If something else changes the value while the `DragValue` has focus,
/// that memorized text is stale, and must not be written back to the value.
///
/// Regression test for <https://github.com/emilk/egui/issues/8339>.
#[test]
pub fn drag_value_should_not_revert_external_changes_while_focused() {
let mut harness = Harness::new_ui_state(
|ui, value: &mut i32| {
ui.add(egui::DragValue::new(value));
},
0,
);
// Focus the `DragValue`, putting it in text-edit mode.
harness.key_press(egui::Key::Tab);
harness.run();
// Something else changes the value while the `DragValue` is focused.
*harness.state_mut() = 42;
harness.run();
assert_eq!(harness.state(), &42);
let drag_value = harness.get_by_role(accesskit::Role::SpinButton);
assert_eq!(drag_value.value(), Some("42".to_owned()));
// Losing focus must not restore the value the `DragValue` had when it gained focus.
harness.key_press(egui::Key::Tab);
harness.run();
assert_eq!(harness.state(), &42);
}
/// While the user is typing into a `DragValue`, the half-finished text must be kept
/// between frames, even though it doesn't always parse back to the same text.
#[test]
pub fn drag_value_should_keep_text_while_typing() {
let mut harness = Harness::new_ui_state(
|ui, value: &mut f64| {
ui.add(egui::DragValue::new(value));
},
0.0,
);
// Focus the `DragValue`, putting it in text-edit mode with the old text selected.
harness.key_press(egui::Key::Tab);
harness.run();
// Type one character per frame. `"1."` parses to `1`, which is formatted as `"1"`,
// so re-reading the text from the value would eat the decimal point.
for character in "1.25".chars() {
harness
.get_by_role(accesskit::Role::SpinButton)
.type_text(&character.to_string());
harness.run();
}
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state(), &1.25);
}
/// An integer `DragValue` cannot represent everything the user types into it,
/// but the text must still survive until the user is done typing.
#[test]
pub fn drag_value_should_keep_text_the_value_cannot_represent() {
let mut harness = Harness::new_ui_state(
|ui, value: &mut i32| {
ui.add(egui::DragValue::new(value));
},
0,
);
// Focus the `DragValue`, putting it in text-edit mode with the old text selected.
harness.key_press(egui::Key::Tab);
harness.run();
// `"12.5"` is stored as `12`, which is formatted as `"12"`.
harness
.get_by_role(accesskit::Role::SpinButton)
.type_text("12.5");
harness.run();
// If the text was re-read from the value now, this would append to `"12"`.
harness
.get_by_role(accesskit::Role::SpinButton)
.type_text("9");
harness.run();
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state(), &12, "The text should have been \"12.59\"");
}