diff --git a/crates/egui/src/theme/default_style.rs b/crates/egui/src/theme/default_style.rs index a23ef8fcd..551f1337e 100644 --- a/crates/egui/src/theme/default_style.rs +++ b/crates/egui/src/theme/default_style.rs @@ -1,12 +1,12 @@ use emath::Vec2; -use epaint::{Shadow, Stroke, text::TextWrapMode}; +use epaint::{Color32, Margin, Shadow, Stroke, text::TextWrapMode}; use crate::{ Frame, TextStyle, theme::StyleProvider, widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, SELECTED_CLASS, - SeparatorStyle, StyleArgs, TextVisuals, WidgetState, + BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, READ_ONLY_CLASS, + SELECTED_CLASS, SeparatorStyle, StyleArgs, TextEditStyle, TextVisuals, WidgetState, }, }; @@ -92,6 +92,61 @@ impl StyleProvider for DefaultStyle { } } +impl StyleProvider for DefaultStyle { + fn style(&mut self, modifiers: &StyleArgs<'_>) -> TextEditStyle { + let StyleArgs { + ctx, + classes, + style, + state, + .. + } = 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, + }; + + // A text edit over an immutable buffer is painted without a background. + let (fill, stroke) = if classes.has(READ_ONLY_CLASS) { + let visuals = &style.visuals.widgets.inactive; + (Color32::TRANSPARENT, visuals.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, + ) + } else { + (style.visuals.text_edit_bg_color(), widget_visuals.bg_stroke) + }; + + let mut ws: BaseStyle = ctx.get_widget_style(modifiers); + + // 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. + ws.text.color = style.visuals.widgets.inactive.text_color(); + + TextEditStyle { + frame: Frame { + 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)), + ..Default::default() + }, + text: ws.text, + hint_text_color: style.visuals.weak_text_color(), + } + } +} + impl StyleProvider for DefaultStyle { fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle { let StyleArgs { diff --git a/crates/egui/src/theme/themes.rs b/crates/egui/src/theme/themes.rs index 4294f9460..5c31f669a 100644 --- a/crates/egui/src/theme/themes.rs +++ b/crates/egui/src/theme/themes.rs @@ -7,7 +7,8 @@ use crate::{ theme::{StyleProvider, default_style::DefaultStyle}, util::IdTypeMap, widget_style::{ - BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, SeparatorStyle, WidgetStyle, + BaseStyle, ButtonStyle, CheckboxStyle, LabelStyle, SeparatorStyle, TextEditStyle, + WidgetStyle, }, }; @@ -51,6 +52,11 @@ impl Default for Themes { Arc::new(Mutex::new(Box::new(DefaultStyle))), ); + themes.insert_temp::>( + Id::NULL, + Arc::new(Mutex::new(Box::new(DefaultStyle))), + ); + themes.insert_temp::>( Id::NULL, Arc::new(Mutex::new(Box::new(DefaultStyle))), diff --git a/crates/egui/src/widget_style/classes.rs b/crates/egui/src/widget_style/classes.rs index 95ef43aae..77f2943f8 100644 --- a/crates/egui/src/widget_style/classes.rs +++ b/crates/egui/src/widget_style/classes.rs @@ -10,6 +10,9 @@ pub const ROOT_CLASS: &str = "root"; /// The selected class is a special class present on selected [`crate::Button`]. pub const SELECTED_CLASS: &str = "selected"; +/// The read-only class is present on a [`crate::TextEdit`] whose buffer can't be edited. +pub const READ_ONLY_CLASS: &str = "read-only"; + /// A class is a static string identifier. pub type ClassName = Cow<'static, str>; @@ -67,6 +70,18 @@ pub trait HasClasses { self } + /// Add all the given classes by consuming `self` + /// + /// Useful to forward the classes of a composite widget to the widgets it is built from. + #[inline] + fn with_classes(mut self, classes: Classes) -> Self + where + Self: Sized, + { + self.classes_mut().classes.extend(classes.classes); + self + } + /// Add the given class by consuming `self` if the condition is true #[inline] fn with_class_if(mut self, class: impl Into, condition: bool) -> Self diff --git a/crates/egui/src/widget_style/mod.rs b/crates/egui/src/widget_style/mod.rs index 1a2c19959..77376a87d 100644 --- a/crates/egui/src/widget_style/mod.rs +++ b/crates/egui/src/widget_style/mod.rs @@ -4,7 +4,9 @@ mod classes; -pub use self::classes::{ClassName, Classes, HasClasses, ROOT_CLASS, SELECTED_CLASS}; +pub use self::classes::{ + ClassName, Classes, HasClasses, READ_ONLY_CLASS, ROOT_CLASS, SELECTED_CLASS, +}; use core::fmt::Debug; @@ -53,6 +55,21 @@ pub struct ButtonStyle { impl WidgetStyle for ButtonStyle {} +/// Dedicated text edit style +#[derive(Debug, Clone)] +pub struct TextEditStyle { + /// Frame around the text, including its padding. + pub frame: Frame, + + /// The text being edited. + pub text: TextVisuals, + + /// The color of the hint text shown while the buffer is empty. + pub hint_text_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..516195673 100644 --- a/crates/egui/src/widgets/drag_value.rs +++ b/crates/egui/src/widgets/drag_value.rs @@ -2,6 +2,7 @@ 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, + widget_style::{Classes, HasClasses}, }; use core::{cmp::Ordering, ops::RangeInclusive}; use emath::Vec2; @@ -63,6 +64,7 @@ pub struct DragValue<'a> { custom_formatter: Option>, custom_parser: Option>, update_while_editing: bool, + classes: Classes, } impl<'a> DragValue<'a> { @@ -97,6 +99,7 @@ impl<'a> DragValue<'a> { custom_formatter: None, custom_parser: None, update_while_editing: true, + classes: Classes::default(), } } @@ -449,6 +452,7 @@ impl Widget for DragValue<'_> { custom_formatter, custom_parser, update_while_editing, + classes, } = self; let mut prefix_text = String::new(); @@ -583,6 +587,7 @@ 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()) @@ -634,6 +639,7 @@ impl Widget for DragValue<'_> { } }); let button = Button::new(atoms) + .with_classes(classes) .wrap_mode(TextWrapMode::Extend) .sense(Sense::click_and_drag()) .gap(0.0) @@ -864,3 +870,13 @@ mod tests { ); } } + +impl HasClasses for DragValue<'_> { + fn classes(&self) -> &Classes { + &self.classes + } + + fn classes_mut(&mut self) -> &mut Classes { + &mut self.classes + } +} diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index 41220ed81..0c4251d3a 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -17,6 +17,7 @@ use crate::{ self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection, }, vec2, + widget_style::{Classes, HasClasses, READ_ONLY_CLASS, TextEditStyle}, }; use super::{TextEditOutput, TextEditState}; @@ -79,7 +80,7 @@ pub struct TextEdit<'t> { layouter: Option>, password: bool, frame: Option, - margin: Margin, + margin: Option, multiline: bool, interactive: bool, desired_width: Option, @@ -92,6 +93,7 @@ pub struct TextEdit<'t> { char_limit: usize, return_key: Option, background_color: Option, + classes: Classes, } impl WidgetWithState for TextEdit<'_> { @@ -133,7 +135,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 +154,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 +311,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 +420,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 +472,38 @@ 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(READ_ONLY_CLASS, !text.is_mutable()); + let TextEditStyle { + frame: styled_frame, + text: text_visuals, + hint_text_color, + } = ui.widget_style(id, &classes); + 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(text_visuals.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 => text_visuals.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; @@ -503,17 +537,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, @@ -549,7 +572,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)) { @@ -591,6 +613,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(margin) = margin { + frame.inner_margin = margin; + } + if let Some(background_color) = background_color { + frame.fill = background_color; + } + frame + }); + let mut get_galley = None; let inner_rect_id = Id::new("text_edit_rect"); let mut response = { @@ -612,7 +645,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(_)) { @@ -637,7 +670,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 @@ -692,9 +725,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; // This wrap mode only affects the hint_text @@ -704,7 +734,7 @@ impl TextEdit<'_> { TextWrapMode::Truncate }; - let mut allocated = AtomLayout::new(atoms) + let allocated = AtomLayout::new(atoms) .id(id) .min_size(Vec2::new(allocate_width, min_height)) .max_width(allocate_width) @@ -714,43 +744,6 @@ impl TextEdit<'_> { .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) };