1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Add StyleProvider support for TextEdit, and classes for DragValue

This commit is contained in:
Lucas Meurer
2026-08-21 14:18:57 +02:00
parent 7aa7f84858
commit 83fe9e0267
6 changed files with 169 additions and 67 deletions

View File

@@ -1,12 +1,12 @@
use emath::Vec2; use emath::Vec2;
use epaint::{Shadow, Stroke, text::TextWrapMode}; use epaint::{Color32, Margin, Shadow, Stroke, text::TextWrapMode};
use crate::{ use crate::{
Frame, TextStyle, Frame, TextStyle,
theme::StyleProvider, theme::StyleProvider,
widget_style::{ widget_style::{
BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, SELECTED_CLASS, BaseStyle, ButtonStyle, CheckboxStyle, HasClasses as _, LabelStyle, READ_ONLY_CLASS,
SeparatorStyle, StyleArgs, TextVisuals, WidgetState, SELECTED_CLASS, SeparatorStyle, StyleArgs, TextEditStyle, TextVisuals, WidgetState,
}, },
}; };
@@ -92,6 +92,61 @@ impl StyleProvider<ButtonStyle> for DefaultStyle {
} }
} }
impl StyleProvider<TextEditStyle> 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<CheckboxStyle> for DefaultStyle { impl StyleProvider<CheckboxStyle> for DefaultStyle {
fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle { fn style(&mut self, modifiers: &StyleArgs<'_>) -> CheckboxStyle {
let StyleArgs { let StyleArgs {

View File

@@ -7,7 +7,8 @@ use crate::{
theme::{StyleProvider, default_style::DefaultStyle}, theme::{StyleProvider, default_style::DefaultStyle},
util::IdTypeMap, util::IdTypeMap,
widget_style::{ 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))), Arc::new(Mutex::new(Box::new(DefaultStyle))),
); );
themes.insert_temp::<ThemeWrap<TextEditStyle>>(
Id::NULL,
Arc::new(Mutex::new(Box::new(DefaultStyle))),
);
themes.insert_temp::<ThemeWrap<LabelStyle>>( themes.insert_temp::<ThemeWrap<LabelStyle>>(
Id::NULL, Id::NULL,
Arc::new(Mutex::new(Box::new(DefaultStyle))), Arc::new(Mutex::new(Box::new(DefaultStyle))),

View File

@@ -10,6 +10,9 @@ pub const ROOT_CLASS: &str = "root";
/// The selected class is a special class present on selected [`crate::Button`]. /// The selected class is a special class present on selected [`crate::Button`].
pub const SELECTED_CLASS: &str = "selected"; 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. /// A class is a static string identifier.
pub type ClassName = Cow<'static, str>; pub type ClassName = Cow<'static, str>;
@@ -67,6 +70,18 @@ pub trait HasClasses {
self 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 /// Add the given class by consuming `self` if the condition is true
#[inline] #[inline]
fn with_class_if(mut self, class: impl Into<ClassName>, condition: bool) -> Self fn with_class_if(mut self, class: impl Into<ClassName>, condition: bool) -> Self

View File

@@ -4,7 +4,9 @@
mod classes; 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; use core::fmt::Debug;
@@ -53,6 +55,21 @@ pub struct ButtonStyle {
impl WidgetStyle for 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 /// Dedicated checkbox style
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CheckboxStyle { pub struct CheckboxStyle {

View File

@@ -2,6 +2,7 @@ use crate::{
Atom, AtomExt as _, AtomKind, Atoms, Button, CursorIcon, Id, IntoAtoms, Key, MINUS_CHAR_STR, Atom, AtomExt as _, AtomKind, Atoms, Button, CursorIcon, Id, IntoAtoms, Key, MINUS_CHAR_STR,
Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, TextWrapMode, Ui, Widget, Modifiers, NumExt as _, Response, RichText, Sense, TextEdit, TextWrapMode, Ui, Widget,
WidgetInfo, emath, text, WidgetInfo, emath, text,
widget_style::{Classes, HasClasses},
}; };
use core::{cmp::Ordering, ops::RangeInclusive}; use core::{cmp::Ordering, ops::RangeInclusive};
use emath::Vec2; use emath::Vec2;
@@ -63,6 +64,7 @@ pub struct DragValue<'a> {
custom_formatter: Option<NumFormatter<'a>>, custom_formatter: Option<NumFormatter<'a>>,
custom_parser: Option<NumParser<'a>>, custom_parser: Option<NumParser<'a>>,
update_while_editing: bool, update_while_editing: bool,
classes: Classes,
} }
impl<'a> DragValue<'a> { impl<'a> DragValue<'a> {
@@ -97,6 +99,7 @@ impl<'a> DragValue<'a> {
custom_formatter: None, custom_formatter: None,
custom_parser: None, custom_parser: None,
update_while_editing: true, update_while_editing: true,
classes: Classes::default(),
} }
} }
@@ -449,6 +452,7 @@ impl Widget for DragValue<'_> {
custom_formatter, custom_formatter,
custom_parser, custom_parser,
update_while_editing, update_while_editing,
classes,
} = self; } = self;
let mut prefix_text = String::new(); 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); .map_or_else(|| value_text.clone(), |edit_state| edit_state.text);
let response = ui.add( let response = ui.add(
TextEdit::singleline(&mut value_text) TextEdit::singleline(&mut value_text)
.with_classes(classes)
.clip_text(false) .clip_text(false)
.horizontal_align(ui.layout().horizontal_align()) .horizontal_align(ui.layout().horizontal_align())
.vertical_align(ui.layout().vertical_align()) .vertical_align(ui.layout().vertical_align())
@@ -634,6 +639,7 @@ impl Widget for DragValue<'_> {
} }
}); });
let button = Button::new(atoms) let button = Button::new(atoms)
.with_classes(classes)
.wrap_mode(TextWrapMode::Extend) .wrap_mode(TextWrapMode::Extend)
.sense(Sense::click_and_drag()) .sense(Sense::click_and_drag())
.gap(0.0) .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
}
}

View File

@@ -17,6 +17,7 @@ use crate::{
self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection, self, CCursorRange, text_cursor_state::cursor_rect, visuals::paint_text_selection,
}, },
vec2, vec2,
widget_style::{Classes, HasClasses, READ_ONLY_CLASS, TextEditStyle},
}; };
use super::{TextEditOutput, TextEditState}; use super::{TextEditOutput, TextEditState};
@@ -79,7 +80,7 @@ pub struct TextEdit<'t> {
layouter: Option<LayouterFn<'t>>, layouter: Option<LayouterFn<'t>>,
password: bool, password: bool,
frame: Option<Frame>, frame: Option<Frame>,
margin: Margin, margin: Option<Margin>,
multiline: bool, multiline: bool,
interactive: bool, interactive: bool,
desired_width: Option<f32>, desired_width: Option<f32>,
@@ -92,6 +93,7 @@ pub struct TextEdit<'t> {
char_limit: usize, char_limit: usize,
return_key: Option<KeyboardShortcut>, return_key: Option<KeyboardShortcut>,
background_color: Option<Color32>, background_color: Option<Color32>,
classes: Classes,
} }
impl WidgetWithState for TextEdit<'_> { impl WidgetWithState for TextEdit<'_> {
@@ -133,7 +135,7 @@ impl<'t> TextEdit<'t> {
layouter: None, layouter: None,
password: false, password: false,
frame: None, frame: None,
margin: Margin::symmetric(4, 2), margin: None,
multiline: true, multiline: true,
interactive: true, interactive: true,
desired_width: None, desired_width: None,
@@ -152,6 +154,7 @@ impl<'t> TextEdit<'t> {
char_limit: usize::MAX, char_limit: usize::MAX,
return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)), return_key: Some(KeyboardShortcut::new(Modifiers::NONE, Key::Enter)),
background_color: None, background_color: None,
classes: Classes::default(),
} }
} }
@@ -308,10 +311,10 @@ impl<'t> TextEdit<'t> {
self 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] #[inline]
pub fn margin(mut self, margin: impl Into<Margin>) -> Self { pub fn margin(mut self, margin: impl Into<Margin>) -> Self {
self.margin = margin.into(); self.margin = Some(margin.into());
self 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<'_> { impl TextEdit<'_> {
/// Show the [`TextEdit`], returning a rich [`TextEditOutput`]. /// Show the [`TextEdit`], returning a rich [`TextEditOutput`].
/// ///
@@ -459,17 +472,38 @@ impl TextEdit<'_> {
char_limit, char_limit,
return_key, return_key,
background_color, background_color,
mut classes,
} = self; } = 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 let text_color = text_color
.or_else(|| ui.visuals().override_text_color) .or_else(|| ui.visuals().override_text_color)
// .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright .unwrap_or(text_visuals.color);
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
let prev_text = text.as_str().to_owned(); let prev_text = text.as_str().to_owned();
let hint_text_str = hint_text.text().unwrap_or_default().to_string(); 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 row_height = ui.fonts_mut(|f| f.row_height(&font_id));
let line_height = row_height + ui.spacing().extra_text_line_spacing; 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 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 // On touch screens (e.g. mobile in `eframe` web), should
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)? // dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
// Since currently copying selected text in not supported on `eframe` web, // Since currently copying selected text in not supported on `eframe` web,
@@ -549,7 +572,6 @@ impl TextEdit<'_> {
} }
let mut text_changed = false; let mut text_changed = false;
let text_mutable = text.is_mutable();
let mut handle_events = |ui: &Ui, galley: &mut Arc<Galley>, layouter, wrap_width, text| { let mut handle_events = |ui: &Ui, galley: &mut Arc<Galley>, layouter, wrap_width, text| {
if interactive && ui.memory(|mem| mem.has_focus(id)) { 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 // 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, // 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. // 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 mut get_galley = None;
let inner_rect_id = Id::new("text_edit_rect"); let inner_rect_id = Id::new("text_edit_rect");
let mut response = { 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. // 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. // 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 { for mut atom in hint_text {
if !shrunk && matches!(atom.kind, AtomKind::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 // 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 // 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); let galley = layouter(ui, text, available_width);
// We can't update the galley immediately here, since it would show both hint text // 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); 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; let min_height = min_inner_height + frame.total_margin().sum().y;
// This wrap mode only affects the hint_text // This wrap mode only affects the hint_text
@@ -704,7 +734,7 @@ impl TextEdit<'_> {
TextWrapMode::Truncate TextWrapMode::Truncate
}; };
let mut allocated = AtomLayout::new(atoms) let allocated = AtomLayout::new(atoms)
.id(id) .id(id)
.min_size(Vec2::new(allocate_width, min_height)) .min_size(Vec2::new(allocate_width, min_height))
.max_width(allocate_width) .max_width(allocate_width)
@@ -714,43 +744,6 @@ impl TextEdit<'_> {
.wrap_mode(wrap_mode) .wrap_mode(wrap_mode)
.allocate(ui); .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) allocated.paint(ui)
}; };