From 9cb53feab4766775c73a5428b95fbffac736d755 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 3 Sep 2026 16:50:30 +0200 Subject: [PATCH] Add tests for `StyleProvider` and ensure every property works (#8477) Follow up to #8455 Based on #8485 --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/egui/src/theme/default_style.rs | 3 +- crates/egui/src/widget_style/mod.rs | 2 + crates/egui/src/widgets/drag_value.rs | 3 +- crates/egui/src/widgets/text_edit/builder.rs | 23 +- crates/egui_demo_lib/src/demo/text_edit.rs | 3 +- crates/egui_kittest/tests/regression_tests.rs | 2 +- tests/egui_tests/tests/regression_tests.rs | 7 +- .../tests/snapshots/text_edit_colors.png | 3 + tests/egui_tests/tests/test_style_provider.rs | 332 ++++++++++++++++++ 9 files changed, 363 insertions(+), 15 deletions(-) create mode 100644 tests/egui_tests/tests/snapshots/text_edit_colors.png create mode 100644 tests/egui_tests/tests/test_style_provider.rs diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index 63ff3b306..dc49899b6 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,4 +1,4 @@ -use emath::Vec2; +use emath::{Align2, Vec2}; use epaint::{Color32, Margin}; use crate::{ @@ -154,6 +154,7 @@ impl StyleProvider for DefaultStyle { .apply_stroke_and_expansion_without_layout_shift(stroke, widget_visuals.expansion), gap: style.spacing.icon_spacing, text_style: text, + align2: Some(Align2::LEFT_TOP), ..Default::default() }, hint_text_color: style.visuals.weak_text_color(), diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index 51866e936..ec885df9d 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -155,6 +155,8 @@ impl WidgetStyle for TextEditStyle {} #[derive(Debug, Clone)] pub struct CheckboxStyle { /// Style of the checkbox's atom layout. + /// + /// [`AtomLayoutStyle::align2`] vertical align has no effect; the checkbox is always centered. pub atom_layout: AtomLayoutStyle, /// Checkbox size diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index 118acb416..c086732db 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -605,8 +605,7 @@ impl Widget for DragValue<'_> { TextEdit::singleline(&mut value_text) .with_classes(classes) .clip_text(false) - .horizontal_align(ui.layout().horizontal_align()) - .vertical_align(ui.layout().vertical_align()) + .align(ui.layout().align2()) .min_size(min_size.unwrap_or_else(|| ui.spacing().interact_size)) .id(id) .desired_width( diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 06e180c09..542255828 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -89,7 +89,7 @@ pub struct TextEdit<'t> { event_filter: EventFilter, cursor_at_end: bool, min_size: Vec2, - align: Align2, + align: Option, clip_text: bool, char_limit: usize, return_key: Option, @@ -153,7 +153,7 @@ impl<'t> TextEdit<'t> { }, cursor_at_end: true, min_size: Vec2::ZERO, - align: Align2::LEFT_TOP, + align: None, clip_text: false, char_limit: usize::MAX, return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)), @@ -382,17 +382,26 @@ impl<'t> TextEdit<'t> { self } + /// Set the align of the inner text. + #[inline] + pub fn align(mut self, align: Align2) -> Self { + self.align = Some(align); + self + } + /// Set the horizontal align of the inner text. + #[deprecated = "Use `align` instead"] #[inline] pub fn horizontal_align(mut self, align: Align) -> Self { - self.align.0[0] = align; + self.align.get_or_insert(Align2::LEFT_TOP).set_x(align); self } /// Set the vertical align of the inner text. + #[deprecated = "Use `align` instead"] #[inline] pub fn vertical_align(mut self, align: Align) -> Self { - self.align.0[1] = align; + self.align.get_or_insert(Align2::LEFT_TOP).set_y(align); self } @@ -501,6 +510,10 @@ impl TextEdit<'_> { // the same way it does for a button. let min_size = min_size.at_least(atom_layout_style.min_size); + let align = align + .or(atom_layout_style.align2) + .unwrap_or_else(|| ui.layout().align2()); + let text_color = text_color .or_else(|| ui.visuals().override_text_color) .unwrap_or(atom_layout_style.text_style.color); @@ -722,7 +735,7 @@ impl TextEdit<'_> { } }) .atom_grow(true) - .atom_align(self.align) + .atom_align(align) .atom_id(inner_rect_id) .atom_shrink(should_shrink), ); diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 5aee8756b..143268fed 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -80,8 +80,7 @@ impl crate::View for TextEditDemo { egui::Atom::custom(clear_id, clear_size) .atom_align(Align2::new(Align::RIGHT, *valign)), ) - .horizontal_align(*halign) - .vertical_align(*valign) + .align(Align2::new(*halign, *valign)) .show(ui); if let Some(rect) = output.response.rect(clear_id) diff --git a/crates/egui_kittest/tests/regression_tests.rs b/crates/egui_kittest/tests/regression_tests.rs index 1f47cd422..9963e42e3 100644 --- a/crates/egui_kittest/tests/regression_tests.rs +++ b/crates/egui_kittest/tests/regression_tests.rs @@ -784,7 +784,7 @@ pub fn textedit_hint_text_should_follow_text_alignment() { egui::TextEdit::singleline(&mut input) .hint_text("Hint") .desired_width(200.0) - .horizontal_align(egui::Align::Center), + .align(egui::Align2::CENTER_TOP), ); }); harness.run(); diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index b72ac6b61..52854536b 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -6,8 +6,8 @@ use egui::epaint::Shape; use egui::style::ScrollAnimation; use egui::text::{LayoutJob, TextWrapping}; use egui::{ - Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, Rect, RichText, Sense, - TextBuffer, TextFormat, TextWrapMode, Ui, Vec2, include_image, vec2, + Align, Align2, Button, Color32, FontFamily, FontId, Image, Label, Layout, Rect, RichText, + Sense, TextBuffer, TextFormat, TextWrapMode, Ui, Vec2, include_image, vec2, }; use egui::{Pos2, ScrollArea}; use egui_kittest::Harness; @@ -131,8 +131,7 @@ fn text_edit_halign() { "{widget_alignment:?}\n+\n{text_alignment:?}", )) .layouter(&mut layouter(text_alignment)) - .vertical_align(widget_alignment) - .horizontal_align(widget_alignment), + .align(Align2::new(widget_alignment, widget_alignment)), ); } }); diff --git a/tests/egui_tests/tests/snapshots/text_edit_colors.png b/tests/egui_tests/tests/snapshots/text_edit_colors.png new file mode 100644 index 000000000..e49a947e5 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/text_edit_colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36d4132bf9f65b57e8a87aea265e770ada3b1a9109c7e6902cf94b12054d013b +size 6831 diff --git a/tests/egui_tests/tests/test_style_provider.rs b/tests/egui_tests/tests/test_style_provider.rs new file mode 100644 index 000000000..ae7669371 --- /dev/null +++ b/tests/egui_tests/tests/test_style_provider.rs @@ -0,0 +1,332 @@ +use core::fmt::Debug; +use egui::theme::{DefaultStyle, StyleProvider}; +use egui::widget_style::{ + AtomLayoutStyle, ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextEditStyle, + TextVisuals, +}; +use egui::{ + Align, Align2, Atom, AtomExt as _, Button, Checkbox, Color32, CornerRadius, FontFamily, FontId, + Frame, Margin, Separator, Stroke, TextEdit, Ui, Vec2, include_image, +}; +use egui_kittest::Harness; + +struct VariantHandle { + calls: usize, + return_b_for: Option, + fail_for: Option, +} + +impl VariantHandle { + #[track_caller] + fn get(&mut self, a: I, b: I) -> I { + assert_ne!( + self.fail_for, + Some(self.calls), + "Switching from {a:?} to {b:?} lead to no meaningful change." + ); + let res = if self.return_b_for == Some(self.calls) { + b + } else { + a + }; + self.calls += 1; + res + } +} + +fn test_variants( + make_variant: impl Fn(&mut VariantHandle) -> Variant, + mut render_variant: impl FnMut(Variant, bool) -> Comparison, +) { + let mut init_handle = VariantHandle { + calls: 0, + return_b_for: None, + fail_for: None, + }; + let base_variant = make_variant(&mut init_handle); + let variant_count = init_handle.calls; + + let base_image = render_variant(base_variant, false); + + for i in 0..variant_count { + let mut test_handle = VariantHandle { + calls: 0, + fail_for: None, + return_b_for: Some(i), + }; + let test_variant = make_variant(&mut test_handle); + let test_image = render_variant(test_variant.clone(), false); + if test_image == base_image { + // Open the snapshot so we can see what the variant looks like. + render_variant(test_variant, true); + + let mut fail_handle = VariantHandle { + calls: 0, + fail_for: Some(i), + return_b_for: None, + }; + make_variant(&mut fail_handle); + } + } +} + +/// Helper that walks all fields of a struct, swapping each value one after the other, ensuring +/// that any value change also results in a visual change of the UI. +fn test_harness_variants( + size: Vec2, + make_variant: impl Fn(&mut VariantHandle) -> Variant, + mut contents: impl FnMut(&mut Ui, Variant), +) { + test_variants(make_variant, |variant, failure| { + let mut harness = Harness::builder().with_size(size).build_ui(|ui| { + contents(ui, variant.clone()); + }); + // Run a few frames so images have time to load. + harness.run(); + if failure { + // Helpful to see what's going on: + // harness.debug_open_snapshot(); + } + harness.render().expect("Failed to render the harness") + }); +} + +struct FixedStyleProvider(T); + +impl StyleProvider for FixedStyleProvider { + fn style(&mut self, _modifiers: &StyleArgs<'_>) -> T { + self.0.clone() + } +} + +/// A small image atom, so [`AtomLayoutStyle::image_tint`] has something to tint. +fn image_atom() -> Atom<'static> { + include_image!("../../../crates/eframe/data/icon.png").atom_size(Vec2::splat(10.0)) +} + +fn frame_variants(variant: &mut VariantHandle) -> Frame { + Frame { + inner_margin: Margin::same(variant.get(0, 4)), + fill: variant.get(Color32::GREEN, Color32::BLUE), + stroke: Stroke::new( + // Has to be more that 0.0 or color variant below will fail + variant.get(1.0, 2.0), + variant.get(Color32::RED, Color32::GREEN), + ), + + corner_radius: variant.get(CornerRadius::same(0), CornerRadius::same(4)), + outer_margin: Margin::same(variant.get(0, 4)), + shadow: Default::default(), + } +} + +/// Which [`AtomLayoutStyle`] fields the widget under test honors. +/// +/// A widget may ignore a field on purpose. Every `false` marks one of those, and says at +/// the call site why the widget ignores it. +#[derive(Clone, Copy)] +struct AtomLayoutFields { + align_y: bool, +} + +impl Default for AtomLayoutFields { + fn default() -> Self { + Self { align_y: true } + } +} + +/// `min_size` is deliberately bigger than the content of every widget here, so `align2` has +/// room to move that content around. +fn atom_layout_variants( + variant: &mut VariantHandle, + fields: AtomLayoutFields, + frame: Frame, +) -> AtomLayoutStyle { + AtomLayoutStyle { + align2: Some(Align2::new( + variant.get(Align::Min, Align::Max), + if fields.align_y { + variant.get(Align::Min, Align::Max) + } else { + Align::Min + }, + )), + min_size: Vec2::new(variant.get(100.0, 200.0), variant.get(40.0, 80.0)), + gap: variant.get(0.0, 10.0), + frame, + text_style: TextVisuals { + font_id: variant.get( + FontId::new(10.0, FontFamily::Proportional), + FontId::new(12.0, FontFamily::Monospace), + ), + color: variant.get(Color32::WHITE, Color32::RED), + }, + image_tint: variant.get(Color32::RED, Color32::GREEN), + } +} + +#[test] +fn ensure_all_button_style_args_used() { + test_harness_variants( + Vec2::new(300.0, 150.0), + |variant| { + let frame = frame_variants(variant); + ButtonStyle { + atom_layout: atom_layout_variants(variant, AtomLayoutFields::default(), frame), + } + }, + |ui, variant| { + egui_extras::install_image_loaders(ui.ctx()); + ui.replace_widget_theme(FixedStyleProvider(variant)); + ui.add(Button::new((image_atom(), "Image Button"))); + }, + ); +} + +/// The [`Checkbox`] paints its box as one rectangle, so only these [`Frame`] fields reach the +/// screen. `outer_margin`, `shadow` and three of the four `inner_margin` sides are dropped. +fn checkbox_frame_variants(variant: &mut VariantHandle) -> Frame { + Frame { + inner_margin: Margin::same(variant.get(0, 4)), + fill: variant.get(Color32::BLACK, Color32::YELLOW), + stroke: Stroke::new( + // Has to be more that 0.0 or the color variant below will fail + variant.get(1.0, 3.0), + variant.get(Color32::WHITE, Color32::RED), + ), + corner_radius: variant.get(CornerRadius::same(0), CornerRadius::same(5)), + outer_margin: Margin::ZERO, + shadow: Default::default(), + } +} + +#[test] +fn ensure_all_checkbox_style_args_used() { + test_harness_variants( + Vec2::new(300.0, 150.0), + |variant| { + let frame = frame_variants(variant); + let fields = AtomLayoutFields { + // A `Checkbox` always centers its box and its label in the row, so the + // vertical align has no effect. This is by design, see `Checkbox::ui`. + align_y: false, + }; + CheckboxStyle { + atom_layout: atom_layout_variants(variant, fields, frame), + checkbox_size: variant.get(14.0, 24.0), + check_size: variant.get(8.0, 13.0), + checkbox_frame: checkbox_frame_variants(variant), + check_stroke: Stroke::new( + // Has to be more that 0.0 or the color variant below will fail + variant.get(1.5, 3.0), + variant.get(Color32::WHITE, Color32::RED), + ), + } + }, + |ui, variant| { + egui_extras::install_image_loaders(ui.ctx()); + ui.replace_widget_theme(FixedStyleProvider(variant)); + // Checked, so `check_size` and `check_stroke` paint something. + let mut checked = true; + ui.add(Checkbox::new(&mut checked, (image_atom(), "On"))); + }, + ); +} + +#[test] +fn ensure_all_separator_style_args_used() { + test_harness_variants( + Vec2::new(300.0, 100.0), + |variant| SeparatorStyle { + spacing: variant.get(6.0, 30.0), + stroke: Stroke::new( + // Has to be more than 0.0 or the color variant below will fail + variant.get(1.0, 4.0), + variant.get(Color32::RED, Color32::GREEN), + ), + }, + |ui, variant| { + ui.replace_widget_theme(FixedStyleProvider(variant)); + ui.add(Separator::default()); + }, + ); +} + +#[test] +fn ensure_all_text_edit_style_args_used() { + test_harness_variants( + Vec2::new(320.0, 260.0), + |variant| { + let frame = frame_variants(variant); + TextEditStyle { + atom_layout: atom_layout_variants(variant, AtomLayoutFields::default(), frame), + hint_text_color: variant.get(Color32::GRAY, Color32::YELLOW), + prefix_suffix_color: variant.get(Color32::BLACK, Color32::BLUE), + } + }, + |ui, variant| { + egui_extras::install_image_loaders(ui.ctx()); + ui.replace_widget_theme(FixedStyleProvider(variant)); + + // Make sure min_size can exceed this: + ui.spacing_mut().text_edit_width = 100.0; + + // An empty field shows the hint text... + let mut empty = String::new(); + ui.add( + TextEdit::singleline(&mut empty) + .hint_text("Hint") + .prefix((image_atom(), "$")) + .suffix(".00"), + ); + + // ...and a filled one shows `text_style.color`. + let mut filled = String::from("Text"); + ui.add( + TextEdit::singleline(&mut filled) + .prefix((image_atom(), "$")) + .suffix(".00"), + ); + }, + ); +} + +struct CustomStyleProvider; + +impl StyleProvider for CustomStyleProvider { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> TextEditStyle { + let mut default: TextEditStyle = DefaultStyle.style(modifiers); + + default.hint_text_color = Color32::BLUE; + default.prefix_suffix_color = Color32::GREEN; + default.atom_layout.text_style.color = Color32::RED; + + default + } +} + +#[test] +fn text_edit_colors() { + let mut harness = Harness::new_ui(|ui| { + ui.add_widget_theme::(CustomStyleProvider); + + ui.label("The text should match the colors:"); + + ui.add( + TextEdit::singleline(&mut String::new()) + .prefix("green") + .suffix("green") + .hint_text("blue"), + ); + + ui.add( + TextEdit::singleline(&mut "Red".to_owned()) + .prefix("green") + .suffix("green") + .hint_text("blue"), + ); + }); + + harness.fit_contents(); + harness.snapshot("text_edit_colors"); +}