From d56bfa92b8cef2867950dc929f969201ba06e308 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Mon, 31 Aug 2026 13:58:39 +0200 Subject: [PATCH] Let the theme style a `TextEdit`'s prefix and suffix `TextEdit` unpacked `AtomLayoutStyle` and re-applied the pieces by hand, which silently dropped `align2` and `image_tint` and made a per-widget gap lose to the theme's. Go through `AtomLayoutStyle::apply`, like `Button` and `Checkbox` do, and add a `prefix_suffix_color` so the atoms around the input keep the general text color rather than the text edit's own. Tidy the provider while here: use `Widgets::state`, split the read-only `fill`/`stroke` decision in two, and take the expansion correction from `Frame::expand_in_place`. Drop `TextVisuals::from_style`, `UiStack::inherited`, `StyleArgs::inherited` and `HasClasses::last_class`, none of which have callers. Co-Authored-By: Claude Opus 5 (1M context) --- crates/egui/src/theme/default_style.rs | 42 +++++++++----------- crates/egui/src/ui_stack.rs | 9 ----- crates/egui/src/widget_style/classes.rs | 11 ----- crates/egui/src/widget_style/mod.rs | 10 ++--- crates/egui/src/widgets/text_edit/builder.rs | 41 +++++++++---------- 5 files changed, 40 insertions(+), 73 deletions(-) diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index 0190233c7..4ad0b2dcc 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,5 +1,5 @@ use emath::Vec2; -use epaint::Margin; +use epaint::{Color32, Margin}; use crate::{ Button, Context, Frame, TextEdit, TextStyle, @@ -114,25 +114,24 @@ impl StyleProvider for DefaultStyle { .. } = modifiers; - let widget_visuals = match state { - WidgetState::Noninteractive => style.visuals.widgets.noninteractive, - WidgetState::Inactive => style.visuals.widgets.inactive, - WidgetState::Hovered => style.visuals.widgets.hovered, - WidgetState::Active => style.visuals.widgets.active, - }; + let widget_visuals = style.visuals.widgets.state(*state); // A text edit over an immutable buffer is painted without a background. - let (fill, stroke) = if classes.has_class(TextEdit::CLASS_READ_ONLY) { - let visuals = &style.visuals.widgets.inactive; - (Color32::TRANSPARENT, visuals.bg_stroke) + let read_only = classes.has_class(TextEdit::CLASS_READ_ONLY); + + let fill = if read_only { + Color32::TRANSPARENT + } else { + style.visuals.text_edit_bg_color() + }; + + let stroke = if read_only { + style.visuals.widgets.inactive.bg_stroke } else if *state == WidgetState::Active { // While focused, the frame is outlined in the selection color. - ( - style.visuals.text_edit_bg_color(), - style.visuals.selection.stroke, - ) + style.visuals.selection.stroke } else { - (style.visuals.text_edit_bg_color(), widget_visuals.bg_stroke) + widget_visuals.bg_stroke }; // The text of a text edit doesn't brighten on hover — that would be distracting while @@ -149,21 +148,16 @@ impl StyleProvider for DefaultStyle { fill, stroke, corner_radius: widget_visuals.corner_radius, - // The stroke is painted centered on the frame edge, so half of it eats into - // the padding; compensate, like the other widgets do. - inner_margin: Margin::symmetric(4, 2) - + Margin::same((widget_visuals.expansion - stroke.width).round() as i8), - outer_margin: Margin::same(-(widget_visuals.expansion as i8)), + inner_margin: Margin::symmetric(4, 2), ..Default::default() - }, - // A text edit sizes itself from the rows it holds; egui's own theme adds no floor - // of its own. - min_size: Vec2::ZERO, + } + .expand_in_place(widget_visuals.expansion), gap: style.spacing.icon_spacing, text_style: text, ..Default::default() }, hint_text_color: style.visuals.weak_text_color(), + prefix_suffix_color: style.visuals.text_color(), } } } diff --git a/crates/egui/src/ui_stack.rs b/crates/egui/src/ui_stack.rs index d9d09f9d5..59b02db90 100644 --- a/crates/egui/src/ui_stack.rs +++ b/crates/egui/src/ui_stack.rs @@ -297,15 +297,6 @@ impl UiStack { self.iter() .any(|node| node.classes.has_class(class.clone())) } - - /// Read a value from the classes up the stack, nearest node first. - /// - /// This is how an inherited property works in a stylesheet: a container states it once, and - /// everything inside picks it up. The nearest [`crate::Ui`] wins, so an inner container can - /// override an outer one. - pub fn inherited(&self, from_classes: impl Fn(&Classes) -> Option) -> Option { - self.iter().find_map(|node| from_classes(&node.classes)) - } } // ---------------------------------------------------------------------------- diff --git a/crates/egui/src/widget_style/classes.rs b/crates/egui/src/widget_style/classes.rs index 5e91d16d6..41aabf3f4 100644 --- a/crates/egui/src/widget_style/classes.rs +++ b/crates/egui/src/widget_style/classes.rs @@ -158,17 +158,6 @@ pub trait HasClasses { fn as_slice(&self) -> &[ClassName] { &self.classes().classes } - - /// The last class that names a `T`, if any. - /// - /// Scanning backwards is what makes the last class win, so a widget given two classes of the - /// same kind takes the one set last — the way a later stylesheet rule overrides an earlier one. - fn last_class(&self, from_class_name: impl Fn(&str) -> Option) -> Option { - self.as_slice() - .iter() - .rev() - .find_map(|class| from_class_name(class)) - } } #[cfg(test)] diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index e365e709f..ef62d800b 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -147,6 +147,9 @@ pub struct TextEditStyle { /// The color of the hint text shown while the buffer is empty. pub hint_text_color: Color32, + + /// The default color of the prefix and suffix atoms. + pub prefix_suffix_color: Color32, } impl WidgetStyle for TextEditStyle {} @@ -238,11 +241,4 @@ impl StyleArgs<'_> { let class = class.into(); self.classes.has_class(class.clone()) || self.stack.has_class(class) } - - /// Read a value from the widget's own classes, falling back to the [`crate::Ui`]s it sits in. - /// - /// See [`UiStack::inherited`] for how the fallback resolves. - pub fn inherited(&self, from_classes: impl Fn(&Classes) -> Option) -> Option { - from_classes(self.classes).or_else(|| self.stack.inherited(from_classes)) - } } diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 8b88c1472..f6291b7a1 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -17,7 +17,7 @@ use crate::{ self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection, }, vec2, - widget_style::{AtomLayoutStyle, Classes, HasClasses, TextEditStyle}, + widget_style::{Classes, HasClasses, TextEditStyle}, }; use super::{TextEditOutput, TextEditState}; @@ -491,30 +491,24 @@ impl TextEdit<'_> { classes.add_class_if(Self::CLASS_READ_ONLY, !text.is_mutable()); let TextEditStyle { - atom_layout: - AtomLayoutStyle { - frame: styled_frame, - min_size: style_min_size, - gap, - text_style: text_visuals, - .. - }, + atom_layout: mut atom_layout_style, hint_text_color, + prefix_suffix_color, } = ui.widget_style(id, &classes); // The theme sets a floor on the size; the builder's own `min_size` can only raise it, // the same way it does for a button. - let min_size = min_size.max(style_min_size); + let min_size = min_size.at_least(atom_layout_style.min_size); let text_color = text_color .or_else(|| ui.visuals().override_text_color) - .unwrap_or(text_visuals.color); + .unwrap_or(atom_layout_style.text_style.color); let prev_text = text.as_str().to_owned(); let hint_text_str = hint_text.text().unwrap_or_default().to_string(); let font_id = match font_selection { - FontSelection::Default => text_visuals.font_id.clone(), + FontSelection::Default => atom_layout_style.text_style.font_id.clone(), font_selection => font_selection.resolve(ui.style()), }; let row_height = ui.fonts_mut(|f| f.row_height(&font_id)); @@ -527,7 +521,7 @@ impl TextEdit<'_> { .at_least(min_size.x); let allocate_width = desired_width.at_most(available_width); - let font_id_clone = font_id.clone(); + let font_id_clone = font_id; let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| { let text = mask_if_password(password, text.as_str()); let mut layout_job = if multiline { @@ -626,16 +620,17 @@ impl TextEdit<'_> { // We need to calculate the galley within the atom closure, so we can calculate it based on // the available width (in case of wrapping multiline text edits). But we show it later, // so we can clip it to the available size. Thus, extract it from the atom closure here. - let frame = frame.unwrap_or_else(|| { - let mut frame = styled_frame; + if let Some(frame) = frame { + atom_layout_style.frame = frame; + } else { if let Some(margin) = margin { - frame.inner_margin = margin; + atom_layout_style.frame.inner_margin = margin; } if let Some(background_color) = background_color { - frame.fill = background_color; + atom_layout_style.frame.fill = background_color; } - frame - }); + } + let frame = atom_layout_style.frame; let mut get_galley = None; let inner_rect_id = Id::new("text_edit_rect"); @@ -747,13 +742,15 @@ impl TextEdit<'_> { TextWrapMode::Truncate }; - let allocated = AtomLayout::new(atoms) + let allocated = atom_layout_style + .apply(AtomLayout::new(atoms)) + // The text being edited gets its color from the layouter, so the only atoms + // left to color are the prefix and the suffix. + .fallback_text_color(prefix_suffix_color) .id(id) - .gap(gap) .min_size(Vec2::new(allocate_width, min_height.at_least(min_size.y))) .max_width(allocate_width) .sense(sense) - .frame(frame) .align2(align) .wrap_mode(wrap_mode) .allocate(ui);