From 7c38c552e4d5ee19c0dad0586dc96d2be404c619 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Wed, 2 Sep 2026 11:42:31 +0200 Subject: [PATCH] StyleProvider for TextEdit and DragValue (#8455) Implements `StyleProvider` for `TextEdit`, so you can fully style it as you please --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/egui/src/memory/mod.rs | 28 ++-- crates/egui/src/theme/default_style.rs | 67 ++++++++- crates/egui/src/widget_style/mod.rs | 18 +++ crates/egui/src/widgets/drag_value.rs | 39 ++++- crates/egui/src/widgets/text_edit/builder.rs | 138 +++++++++--------- .../snapshots/text_edit_delay_0_empty.png | 4 +- .../tests/snapshots/text_edit_halign.png | 4 +- .../tests/snapshots/text_edit_rtl_0.png | 4 +- .../snapshots/text_edit_scroll_0_focus.png | 4 +- .../tests/snapshots/visuals/drag_value.png | 4 +- 10 files changed, 212 insertions(+), 98 deletions(-) diff --git a/crates/egui/src/memory/mod.rs b/crates/egui/src/memory/mod.rs index 4910d81da..436798a2e 100644 --- a/crates/egui/src/memory/mod.rs +++ b/crates/egui/src/memory/mod.rs @@ -516,8 +516,6 @@ pub(crate) struct Focus { /// The ID of a widget to give the focus to in the next frame. id_next_frame: Option, - id_requested_by_accesskit: Option, - /// If set, the next widget that is interested in focus will automatically get it. /// Probably because the user pressed Tab. give_to_next: bool, @@ -575,10 +573,10 @@ impl Focus { } let event_filter = self.focused_widget.map(|w| w.filter).unwrap_or_default(); - self.id_requested_by_accesskit = None; - self.focus_direction = FocusDirection::None; + let mut focus_requested_by_accesskit = None; + for event in &new_input.events { if !event_filter.matches(event) && let crate::Event::Key { @@ -615,9 +613,22 @@ impl Focus { }) = event && *target_tree == accesskit::TreeId::ROOT { - self.id_requested_by_accesskit = Some(*target_node); + focus_requested_by_accesskit = Some(*target_node); } } + + // Handle accesskit focus requests + let newly_focused = focus_requested_by_accesskit.and_then(|node_id| { + self.focus_widgets_cache + .keys() + .find(|id| id.accesskit_id() == node_id) + .copied() + }); + if let Some(id) = newly_focused { + self.focused_widget = Some(FocusWidget::new(id)); + self.give_to_next = false; + self.reset_focus(); + } } pub(crate) fn end_pass(&mut self, used_ids: &IdMap) { @@ -645,13 +656,6 @@ impl Focus { } fn interested_in_focus(&mut self, id: Id) { - if self.id_requested_by_accesskit == Some(id.accesskit_id()) { - self.focused_widget = Some(FocusWidget::new(id)); - self.id_requested_by_accesskit = None; - self.give_to_next = false; - self.reset_focus(); - } - // The rect is updated at the end of the frame. self.focus_widgets_cache .entry(id) diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index 877132e78..63ff3b306 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,13 +1,13 @@ use emath::Vec2; -use epaint::Margin; +use epaint::{Color32, Margin}; use crate::{ - Button, Context, Frame, TextStyle, + Button, Context, Frame, TextEdit, TextStyle, class::HasClasses as _, theme::StyleProvider, widget_style::{ - AtomLayoutStyle, ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextVisuals, - WidgetState, + AtomLayoutStyle, ButtonStyle, CheckboxStyle, SeparatorStyle, StyleArgs, TextEditStyle, + TextVisuals, WidgetState, }, }; @@ -25,6 +25,7 @@ impl DefaultStyle { ctx.add_widget_theme::(Self); ctx.add_widget_theme::(Self); ctx.add_widget_theme::(Self); + ctx.add_widget_theme::(Self); } } @@ -85,7 +86,7 @@ impl StyleProvider for DefaultStyle { let image_tint = if classes.has_class(&Button::CLASS_IMAGE_TINT_FOLLOWS_TEXT_COLOR) { text_style.color } else { - crate::Color32::WHITE + Color32::WHITE }; ButtonStyle { @@ -105,6 +106,62 @@ impl StyleProvider for DefaultStyle { } } +impl StyleProvider for DefaultStyle { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> TextEditStyle { + let StyleArgs { + classes, + style, + state, + .. + } = modifiers; + + let widget_visuals = style.visuals.widgets.state(*state); + + // A text edit over an immutable buffer is painted without a background. + 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.selection.stroke + } else { + widget_visuals.bg_stroke + }; + + // The text of a text edit doesn't brighten on hover — that would be distracting while + // typing — so it keeps the inactive color no matter the state. + let text = TextVisuals::from_widget_visuals( + style, + TextStyle::Body, + &style.visuals.widgets.inactive, + ); + + TextEditStyle { + atom_layout: AtomLayoutStyle { + frame: Frame { + fill, + corner_radius: widget_visuals.corner_radius, + inner_margin: Margin::symmetric(4, 2), + ..Default::default() + } + .apply_stroke_and_expansion_without_layout_shift(stroke, 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(), + } + } +} + impl StyleProvider for DefaultStyle { fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle { let StyleArgs { style, state, .. } = modifiers; diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index ffb40ddea..51866e936 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -133,6 +133,24 @@ pub struct ButtonStyle { impl WidgetStyle for ButtonStyle {} +/// Dedicated text edit style +#[derive(Debug, Clone)] +pub struct TextEditStyle { + /// Style of the field's atom layout. + /// + /// [`AtomLayoutStyle::frame`] surrounds the text, including its padding, and + /// [`AtomLayoutStyle::text_style`] is the text being edited. + pub atom_layout: AtomLayoutStyle, + + /// 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 {} + /// Dedicated checkbox style #[derive(Debug, Clone)] pub struct CheckboxStyle { diff --git a/crates/egui/src/widgets/drag_value.rs b/crates/egui/src/widgets/drag_value.rs index eed264a82..118acb416 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -1,7 +1,9 @@ use crate::{ Atom, AtomExt as _, AtomKind, Atoms, Button, CursorIcon, Id, IntoAtoms, Key, MINUS_CHAR_STR, Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, TextWrapMode, Ui, Widget, - WidgetInfo, emath, text, + WidgetInfo, + class::{ClassName, Classes, HasClasses}, + emath, text, }; use core::{cmp::Ordering, ops::RangeInclusive}; use emath::Vec2; @@ -63,11 +65,16 @@ pub struct DragValue<'a> { custom_formatter: Option>, custom_parser: Option>, update_while_editing: bool, + classes: Classes, + min_size: Option, } impl<'a> DragValue<'a> { const ATOM_ID: &'static str = "drag_item"; + /// Present on the [`Button`] and the [`TextEdit`] a drag value is built from. + pub const CLASS: ClassName = ClassName::from_static("egui::drag_value"); + pub fn new(value: &'a mut Num) -> Self { let slf = Self::from_get_set(move |v: Option| { if let Some(v) = v { @@ -97,9 +104,20 @@ impl<'a> DragValue<'a> { custom_formatter: None, custom_parser: None, update_while_editing: true, + classes: Classes::default().with_class(Self::CLASS), + min_size: None, } } + /// The minimum size of the drag value, in both its dragged and its text-edited state. + /// + /// Defaults to [`crate::style::Spacing::interact_size`]. + #[inline] + pub fn min_size(mut self, min_size: Vec2) -> Self { + self.min_size = Some(min_size); + self + } + /// How much the value changes when dragged one point (logical pixel). /// /// Should be finite and greater than zero. @@ -449,6 +467,8 @@ impl Widget for DragValue<'_> { custom_formatter, custom_parser, update_while_editing, + classes, + min_size, } = self; let mut prefix_text = String::new(); @@ -583,11 +603,11 @@ impl Widget for DragValue<'_> { .map_or_else(|| value_text.clone(), |edit_state| edit_state.text); let response = ui.add( TextEdit::singleline(&mut value_text) + .with_classes(classes) .clip_text(false) .horizontal_align(ui.layout().horizontal_align()) .vertical_align(ui.layout().vertical_align()) - .margin(ui.spacing().button_padding) - .min_size(ui.spacing().interact_size) + .min_size(min_size.unwrap_or_else(|| ui.spacing().interact_size)) .id(id) .desired_width( ui.spacing().interact_size.x - 2.0 * ui.spacing().button_padding.x, @@ -634,10 +654,11 @@ impl Widget for DragValue<'_> { } }); let button = Button::new(atoms) + .with_classes(classes) .wrap_mode(TextWrapMode::Extend) .sense(Sense::click_and_drag()) .gap(0.0) - .min_size(ui.spacing().interact_size); // TODO(emilk): find some more generic solution to `min_size` + .min_size(min_size.unwrap_or_else(|| ui.spacing().interact_size)); // TODO(emilk): find some more generic solution to `min_size` let cursor_icon = if value <= *range.start() { CursorIcon::ResizeEast @@ -805,6 +826,16 @@ fn select_all_text(ui: &Ui, widget_id: Id, response_id: Id, value_text: &str) { state.store(ui.ctx(), response_id); } +impl HasClasses for DragValue<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} + #[cfg(test)] mod tests { use super::clamp_value_to_range; diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 90d2de0d5..06e180c09 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -8,6 +8,7 @@ use crate::{ CursorIcon, Event, EventFilter, FontSelection, Frame, IMEPurpose, Id, IdSalt, ImeEvent, IntoAtoms, IntoSizedResult, Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, SizedAtomKind, TextBuffer, TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, + class::{ClassName, Classes, HasClasses}, epaint, os::OperatingSystem, output::OutputEvent, @@ -17,6 +18,7 @@ use crate::{ self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection, }, vec2, + widget_style::TextEditStyle, }; use super::{TextEditOutput, TextEditState}; @@ -79,7 +81,7 @@ pub struct TextEdit<'t> { layouter: Option>, password: bool, frame: Option, - margin: Margin, + margin: Option, multiline: bool, interactive: bool, desired_width: Option, @@ -92,6 +94,7 @@ pub struct TextEdit<'t> { char_limit: usize, return_key: Option, background_color: Option, + classes: Classes, } impl WidgetWithState for TextEdit<'_> { @@ -99,6 +102,9 @@ impl WidgetWithState for TextEdit<'_> { } impl TextEdit<'_> { + /// Present on a text edit whose buffer can't be edited. + pub const CLASS_READ_ONLY: ClassName = ClassName::from_static("egui::text_edit::read_only"); + pub fn load_state(ctx: &Context, id: Id) -> Option { TextEditState::load(ctx, id) } @@ -133,7 +139,7 @@ impl<'t> TextEdit<'t> { layouter: None, password: false, frame: None, - margin: Margin::symmetric(4, 2), + margin: None, multiline: true, interactive: true, desired_width: None, @@ -152,6 +158,7 @@ impl<'t> TextEdit<'t> { char_limit: usize::MAX, return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)), background_color: None, + classes: Classes::default(), } } @@ -308,10 +315,10 @@ impl<'t> TextEdit<'t> { self } - /// Set margin of text. Default is `Margin::symmetric(4.0, 2.0)` + /// Override the padding around the text, which otherwise comes from the theme. #[inline] pub fn margin(mut self, margin: impl Into) -> Self { - self.margin = margin.into(); + self.margin = Some(margin.into()); self } @@ -417,6 +424,16 @@ impl Widget for TextEdit<'_> { } } +impl HasClasses for TextEdit<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} + impl TextEdit<'_> { /// Show the [`TextEdit`], returning a rich [`TextEditOutput`]. /// @@ -459,17 +476,42 @@ impl TextEdit<'_> { char_limit, return_key, background_color, + mut classes, } = self; + let id = id.unwrap_or_else(|| { + if let Some(id_salt) = id_salt { + ui.make_persistent_id(id_salt) + } else { + // Since we are only storing the cursor a persistent Id is not super important + let id = ui.next_auto_id(); + ui.skip_ahead_auto_ids(1); + id + } + }); + + classes.add_class_if(Self::CLASS_READ_ONLY, !text.is_mutable()); + let TextEditStyle { + 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.at_least(atom_layout_style.min_size); + let text_color = text_color .or_else(|| ui.visuals().override_text_color) - // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright - .unwrap_or_else(|| ui.visuals().widgets.inactive.text_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 = font_selection.resolve(ui.style()); + let font_id = match font_selection { + 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)); let line_height = row_height + ui.spacing().extra_text_line_spacing; @@ -481,13 +523,12 @@ impl TextEdit<'_> { // `min_size` overrides available width let allocate_width = desired_width.at_most(available_width).at_least(min_size.x); - let font_id_clone = font_id.clone(); 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 { - LayoutJob::simple(text, font_id_clone.clone(), text_color, wrap_width) + LayoutJob::simple(text, font_id.clone(), text_color, wrap_width) } else { - LayoutJob::simple_singleline(text, font_id_clone.clone(), text_color) + LayoutJob::simple_singleline(text, font_id.clone(), text_color) }; layout_job.halign = align.x(); // We want to keep the trailing whitespace, since hiding it feels really weird when typing @@ -504,17 +545,6 @@ impl TextEdit<'_> { let min_inner_height = (desired_height_rows.at_least(1) as f32) * line_height; - let id = id.unwrap_or_else(|| { - if let Some(id_salt) = id_salt { - ui.make_persistent_id(id_salt) - } else { - // Since we are only storing the cursor a persistent Id is not super important - let id = ui.next_auto_id(); - ui.skip_ahead_auto_ids(1); - id - } - }); - // On touch screens (e.g. mobile in `eframe` web), should // dragging select text, or scroll the enclosing [`ScrollArea`] (if any)? // Since currently copying selected text in not supported on `eframe` web, @@ -550,7 +580,6 @@ impl TextEdit<'_> { } let mut text_changed = false; - let text_mutable = text.is_mutable(); let mut handle_events = |ui: &Ui, galley: &mut Arc, layouter, wrap_width, text| { if interactive && ui.memory(|mem| mem.has_focus(id)) { @@ -592,6 +621,18 @@ 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. + if let Some(frame) = frame { + atom_layout_style.frame = frame; + } else { + if let Some(margin) = margin { + atom_layout_style.frame.inner_margin = margin; + } + if let Some(background_color) = background_color { + atom_layout_style.frame.fill = background_color; + } + } + let frame = atom_layout_style.frame; + let mut get_galley = None; let inner_rect_id = Id::new("text_edit_rect"); let mut response = { @@ -613,7 +654,7 @@ impl TextEdit<'_> { // Since we can't set a fallback color per atom, we have to override it here. // Sucks, since it means users won't be able to override it. - hint_text.map_texts(|t| t.color(ui.style().visuals.weak_text_color())); + hint_text.map_texts(|t| t.color(hint_text_color)); for mut atom in hint_text { if !shrunk && matches!(atom.kind, AtomKind::Text(_)) { @@ -638,7 +679,7 @@ impl TextEdit<'_> { // Calculate the empty galley, so it can be read later. The available width is // technically wrong, but doesn't matter since the galley is empty - let available_width = allocate_width - margin.sum().x; + let available_width = allocate_width - frame.total_margin().sum().x; let galley = layouter(ui, text, available_width); // We can't update the galley immediately here, since it would show both hint text @@ -693,9 +734,6 @@ impl TextEdit<'_> { atoms.push_right(atom); } - let custom_frame = frame.is_some(); - let frame = frame.unwrap_or_else(|| Frame::new().inner_margin(margin)); - let min_height = (min_inner_height + frame.total_margin().sum().y).at_least(min_size.y); // This wrap mode only affects the hint_text @@ -705,53 +743,19 @@ impl TextEdit<'_> { TextWrapMode::Truncate }; - let mut 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) - .min_size(Vec2::new(allocate_width, min_height)) + .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); - allocated.frame = if custom_frame { - allocated.frame - } else { - let visuals = ui.style().interact(&allocated.response); - let background_color = - background_color.unwrap_or_else(|| ui.visuals().text_edit_bg_color()); - - let (corner_radius, background_color, stroke) = if text_mutable { - if allocated.response.has_focus() { - ( - visuals.corner_radius, - background_color, - ui.visuals().selection.stroke, - ) - } else { - (visuals.corner_radius, background_color, visuals.bg_stroke) - } - } else { - let visuals = &ui.style().visuals.widgets.inactive; - ( - visuals.corner_radius, - Color32::TRANSPARENT, - visuals.bg_stroke, - ) - }; - allocated - .frame - .fill(background_color) - .corner_radius(corner_radius) - .inner_margin( - allocated.frame.inner_margin - + Margin::same((visuals.expansion - stroke.width).round() as i8), - ) - .outer_margin(Margin::same(-(visuals.expansion as i8))) - .stroke(stroke) - }; - allocated.paint(ui) }; diff --git a/tests/egui_tests/tests/snapshots/text_edit_delay_0_empty.png b/tests/egui_tests/tests/snapshots/text_edit_delay_0_empty.png index 7f85cf977..89de59f1d 100644 --- a/tests/egui_tests/tests/snapshots/text_edit_delay_0_empty.png +++ b/tests/egui_tests/tests/snapshots/text_edit_delay_0_empty.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:570216d6ffa3cc278705582d95b96200bc3ef1608b8f2983a6ed3f8b2ee7276c -size 2280 +oid sha256:806f3f69228e62d9d0838fb27a02f46cae29efffee54d8182ecbfd2c12f405ba +size 2413 diff --git a/tests/egui_tests/tests/snapshots/text_edit_halign.png b/tests/egui_tests/tests/snapshots/text_edit_halign.png index 31c19aede..4cb5beade 100644 --- a/tests/egui_tests/tests/snapshots/text_edit_halign.png +++ b/tests/egui_tests/tests/snapshots/text_edit_halign.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b66afca19d0a0a6fac4737816443fbd481635897807689dbd75321360ffa079d -size 13803 +oid sha256:2ae71de200dfb36f64e1ce95c2ad78ef3c1e88e792ada5b5fa1d8fa8c938d8fd +size 13806 diff --git a/tests/egui_tests/tests/snapshots/text_edit_rtl_0.png b/tests/egui_tests/tests/snapshots/text_edit_rtl_0.png index b4338fa85..3a3f83593 100644 --- a/tests/egui_tests/tests/snapshots/text_edit_rtl_0.png +++ b/tests/egui_tests/tests/snapshots/text_edit_rtl_0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:20afbde3e1edc7a599936f83188fb1a2c35d2d1344ce7ad8e115b937266481fc -size 2547 +oid sha256:10459c5ac931ee55d814e7e4122167f1d4050fff39c409cef995c91d6deaef55 +size 2563 diff --git a/tests/egui_tests/tests/snapshots/text_edit_scroll_0_focus.png b/tests/egui_tests/tests/snapshots/text_edit_scroll_0_focus.png index 01076e11f..beb304759 100644 --- a/tests/egui_tests/tests/snapshots/text_edit_scroll_0_focus.png +++ b/tests/egui_tests/tests/snapshots/text_edit_scroll_0_focus.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b0af10fca47b33752aed2549528dba6b6e8c4818f2fc2b9de60451afdd30a2b -size 1690 +oid sha256:123f7d1a86d644d2e830a2c8b73e8c509ce33fd5d3ead28d3eccf6180a2fcfc5 +size 1790 diff --git a/tests/egui_tests/tests/snapshots/visuals/drag_value.png b/tests/egui_tests/tests/snapshots/visuals/drag_value.png index 75eca60ce..89f800fc9 100644 --- a/tests/egui_tests/tests/snapshots/visuals/drag_value.png +++ b/tests/egui_tests/tests/snapshots/visuals/drag_value.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65161f878e9f6609f8b8a8898ad6c11ef3a730682389b6ea5e3fb6534277cb34 -size 9247 +oid sha256:73c293f63b9b7f02fd0f3a506dd076eb1e653b7fbb7554603e457b9ad2552f48 +size 9276