mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
TextEdit Atom prefix/suffix (#7587)
* part of https://github.com/emilk/egui/issues/7264 * part of https://github.com/emilk/egui/issues/7445 This PR changes the layout within the TextEdit to be done with AtomLayout. It also adds a Prefix and Postfix that allows adding permanent icons / icon buttons within the textedit. Breaking changes: - Removed `TextEdit::hint_text_font`. Hint text is an atom now, so the font can be set that way <img width="264" height="130" alt="Screenshot 2025-10-06 at 12 25 21" src="https://github.com/user-attachments/assets/03b6b56b-ca82-4ac3-b5c0-585cca336834" /> --------- Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com> Co-authored-by: lucasmerlin <8009393+lucasmerlin@users.noreply.github.com>
This commit is contained in:
@@ -203,7 +203,7 @@ impl<'a> AtomLayout<'a> {
|
||||
// If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`.
|
||||
// If none is found, mark the first text item as `shrink`.
|
||||
if wrap_mode != TextWrapMode::Extend {
|
||||
let any_shrink = atoms.iter().any(|a| a.shrink);
|
||||
let any_shrink = atoms.any_shrink();
|
||||
if !any_shrink {
|
||||
let first_text = atoms
|
||||
.iter_mut()
|
||||
|
||||
@@ -69,6 +69,11 @@ impl<'a> Atoms<'a> {
|
||||
string
|
||||
}
|
||||
|
||||
/// Do any of the atoms have shrink set to `true`?
|
||||
pub fn any_shrink(&self) -> bool {
|
||||
self.iter().any(|a| a.shrink)
|
||||
}
|
||||
|
||||
pub fn iter_kinds(&self) -> impl Iterator<Item = &AtomKind<'a>> {
|
||||
self.0.iter().map(|atom| &atom.kind)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use emath::{Rect, TSTransform};
|
||||
use epaint::{
|
||||
StrokeKind,
|
||||
text::{Galley, LayoutJob, cursor::CCursor},
|
||||
};
|
||||
use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor};
|
||||
|
||||
use crate::{
|
||||
Align, Align2, Color32, Context, CursorIcon, Event, EventFilter, FontSelection, Id, ImeEvent,
|
||||
Key, KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, Shape, TextBuffer,
|
||||
TextStyle, TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetWithState, epaint,
|
||||
Align, Align2, Atom, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, CursorIcon,
|
||||
Event, EventFilter, FontSelection, Frame, Id, ImeEvent, IntoAtoms, IntoSizedResult, Key,
|
||||
KeyboardShortcut, Margin, Modifiers, NumExt as _, Response, Sense, SizedAtomKind, TextBuffer,
|
||||
TextStyle, Ui, Vec2, Widget, WidgetInfo, WidgetWithState, epaint,
|
||||
os::OperatingSystem,
|
||||
output::OutputEvent,
|
||||
response, text_selection,
|
||||
@@ -67,15 +65,16 @@ type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley
|
||||
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
|
||||
pub struct TextEdit<'t> {
|
||||
text: &'t mut dyn TextBuffer,
|
||||
hint_text: WidgetText,
|
||||
hint_text_font: Option<FontSelection>,
|
||||
prefix: Atoms<'static>,
|
||||
suffix: Atoms<'static>,
|
||||
hint_text: Atoms<'static>,
|
||||
id: Option<Id>,
|
||||
id_salt: Option<Id>,
|
||||
font_selection: FontSelection,
|
||||
text_color: Option<Color32>,
|
||||
layouter: Option<LayouterFn<'t>>,
|
||||
password: bool,
|
||||
frame: bool,
|
||||
frame: Option<Frame>,
|
||||
margin: Margin,
|
||||
multiline: bool,
|
||||
interactive: bool,
|
||||
@@ -120,15 +119,16 @@ impl<'t> TextEdit<'t> {
|
||||
pub fn multiline(text: &'t mut dyn TextBuffer) -> Self {
|
||||
Self {
|
||||
text,
|
||||
prefix: Default::default(),
|
||||
suffix: Default::default(),
|
||||
hint_text: Default::default(),
|
||||
hint_text_font: None,
|
||||
id: None,
|
||||
id_salt: None,
|
||||
font_selection: Default::default(),
|
||||
text_color: None,
|
||||
layouter: None,
|
||||
password: false,
|
||||
frame: true,
|
||||
frame: None,
|
||||
margin: Margin::symmetric(4, 2),
|
||||
multiline: true,
|
||||
interactive: true,
|
||||
@@ -202,8 +202,22 @@ impl<'t> TextEdit<'t> {
|
||||
/// # });
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn hint_text(mut self, hint_text: impl Into<WidgetText>) -> Self {
|
||||
self.hint_text = hint_text.into();
|
||||
pub fn hint_text(mut self, hint_text: impl IntoAtoms<'static>) -> Self {
|
||||
self.hint_text = hint_text.into_atoms();
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a prefix to the text edit. This will always be shown before the editable text.
|
||||
#[inline]
|
||||
pub fn prefix(mut self, prefix: impl IntoAtoms<'static>) -> Self {
|
||||
self.prefix = prefix.into_atoms();
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a suffix to the text edit. This will always be shown after the editable text.
|
||||
#[inline]
|
||||
pub fn suffix(mut self, suffix: impl IntoAtoms<'static>) -> Self {
|
||||
self.suffix = suffix.into_atoms();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -215,13 +229,6 @@ impl<'t> TextEdit<'t> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a specific style for the hint text.
|
||||
#[inline]
|
||||
pub fn hint_text_font(mut self, hint_text_font: impl Into<FontSelection>) -> Self {
|
||||
self.hint_text_font = Some(hint_text_font.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// If true, hide the letters from view and prevent copying from the field.
|
||||
#[inline]
|
||||
pub fn password(mut self, password: bool) -> Self {
|
||||
@@ -290,10 +297,10 @@ impl<'t> TextEdit<'t> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Default is `true`. If set to `false` there will be no frame showing that this is editable text!
|
||||
/// Customize the [`Frame`] around the text edit.
|
||||
#[inline]
|
||||
pub fn frame(mut self, frame: bool) -> Self {
|
||||
self.frame = frame;
|
||||
pub fn frame(mut self, frame: Frame) -> Self {
|
||||
self.frame = Some(frame);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -423,63 +430,18 @@ impl TextEdit<'_> {
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn show(self, ui: &mut Ui) -> TextEditOutput {
|
||||
let is_mutable = self.text.is_mutable();
|
||||
let frame = self.frame;
|
||||
let where_to_put_background = ui.painter().add(Shape::Noop);
|
||||
let background_color = self
|
||||
.background_color
|
||||
.unwrap_or_else(|| ui.visuals().text_edit_bg_color());
|
||||
let output = self.show_content(ui);
|
||||
|
||||
if frame {
|
||||
let visuals = ui.style().interact(&output.response);
|
||||
let frame_rect = output.response.rect.expand(visuals.expansion);
|
||||
let shape = if is_mutable {
|
||||
if output.response.has_focus() {
|
||||
epaint::RectShape::new(
|
||||
frame_rect,
|
||||
visuals.corner_radius,
|
||||
background_color,
|
||||
ui.visuals().selection.stroke,
|
||||
StrokeKind::Inside,
|
||||
)
|
||||
} else {
|
||||
epaint::RectShape::new(
|
||||
frame_rect,
|
||||
visuals.corner_radius,
|
||||
background_color,
|
||||
visuals.bg_stroke, // TODO(emilk): we want to show something here, or a text-edit field doesn't "pop".
|
||||
StrokeKind::Inside,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let visuals = &ui.style().visuals.widgets.inactive;
|
||||
epaint::RectShape::stroke(
|
||||
frame_rect,
|
||||
visuals.corner_radius,
|
||||
visuals.bg_stroke, // TODO(emilk): we want to show something here, or a text-edit field doesn't "pop".
|
||||
StrokeKind::Inside,
|
||||
)
|
||||
};
|
||||
|
||||
ui.painter().set(where_to_put_background, shape);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn show_content(self, ui: &mut Ui) -> TextEditOutput {
|
||||
let TextEdit {
|
||||
text,
|
||||
hint_text,
|
||||
hint_text_font,
|
||||
prefix,
|
||||
suffix,
|
||||
mut hint_text,
|
||||
id,
|
||||
id_salt,
|
||||
font_selection,
|
||||
text_color,
|
||||
layouter,
|
||||
password,
|
||||
frame: _,
|
||||
frame,
|
||||
margin,
|
||||
multiline,
|
||||
interactive,
|
||||
@@ -492,7 +454,7 @@ impl TextEdit<'_> {
|
||||
clip_text,
|
||||
char_limit,
|
||||
return_key,
|
||||
background_color: _,
|
||||
background_color,
|
||||
} = self;
|
||||
|
||||
let text_color = text_color
|
||||
@@ -501,18 +463,16 @@ impl TextEdit<'_> {
|
||||
.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color());
|
||||
|
||||
let prev_text = text.as_str().to_owned();
|
||||
let hint_text_str = hint_text.text().to_owned();
|
||||
let hint_text_str = hint_text.text().unwrap_or_default().to_string();
|
||||
|
||||
let font_id = font_selection.resolve(ui.style());
|
||||
let row_height = ui.fonts_mut(|f| f.row_height(&font_id));
|
||||
const MIN_WIDTH: f32 = 24.0; // Never make a [`TextEdit`] more narrow than this.
|
||||
let available_width = (ui.available_width() - margin.sum().x).at_least(MIN_WIDTH);
|
||||
let desired_width = desired_width.unwrap_or_else(|| ui.spacing().text_edit_width);
|
||||
let wrap_width = if ui.layout().horizontal_justify() {
|
||||
available_width
|
||||
} else {
|
||||
desired_width.min(available_width)
|
||||
};
|
||||
let available_width = ui.available_width().at_least(MIN_WIDTH);
|
||||
let desired_width = desired_width
|
||||
.unwrap_or_else(|| ui.spacing().text_edit_width)
|
||||
.at_least(min_size.x);
|
||||
let allocate_width = desired_width.at_most(available_width);
|
||||
|
||||
let font_id_clone = font_id.clone();
|
||||
let mut default_layouter = move |ui: &Ui, text: &dyn TextBuffer, wrap_width: f32| {
|
||||
@@ -527,27 +487,18 @@ impl TextEdit<'_> {
|
||||
|
||||
let layouter = layouter.unwrap_or(&mut default_layouter);
|
||||
|
||||
let mut galley = layouter(ui, text, wrap_width);
|
||||
|
||||
let desired_inner_width = if clip_text {
|
||||
wrap_width // visual clipping with scroll in singleline input.
|
||||
} else {
|
||||
galley.size().x.max(wrap_width)
|
||||
};
|
||||
let desired_height = (desired_height_rows.at_least(1) as f32) * row_height;
|
||||
let desired_inner_size = vec2(desired_inner_width, galley.size().y.max(desired_height));
|
||||
let desired_outer_size = (desired_inner_size + margin.sum()).at_least(min_size);
|
||||
let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
|
||||
let rect = outer_rect - margin; // inner rect (excluding frame/margin).
|
||||
let min_inner_height = (desired_height_rows.at_least(1) as f32) * row_height;
|
||||
|
||||
let id = id.unwrap_or_else(|| {
|
||||
if let Some(id_salt) = id_salt {
|
||||
ui.make_persistent_id(id_salt)
|
||||
} else {
|
||||
auto_id // Since we are only storing the cursor a persistent Id is not super important
|
||||
// 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
|
||||
}
|
||||
});
|
||||
let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
|
||||
// On touch screens (e.g. mobile in `eframe` web), should
|
||||
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
|
||||
@@ -565,12 +516,215 @@ impl TextEdit<'_> {
|
||||
} else {
|
||||
Sense::hover()
|
||||
};
|
||||
let mut response = ui.interact(outer_rect, id, sense);
|
||||
response.intrinsic_size = Some(Vec2::new(desired_width, desired_outer_size.y));
|
||||
|
||||
// Don't sent `OutputEvent::Clicked` when a user presses the space bar
|
||||
let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
|
||||
let mut cursor_range = None;
|
||||
let mut prev_cursor_range = None;
|
||||
|
||||
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| {
|
||||
if interactive && ui.memory(|mem| mem.has_focus(id)) {
|
||||
ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter));
|
||||
|
||||
let default_cursor_range = if cursor_at_end {
|
||||
CCursorRange::one(galley.end())
|
||||
} else {
|
||||
CCursorRange::default()
|
||||
};
|
||||
prev_cursor_range = state.cursor.range(galley);
|
||||
|
||||
let (changed, new_cursor_range) = events(
|
||||
ui,
|
||||
&mut state,
|
||||
text,
|
||||
galley,
|
||||
layouter,
|
||||
id,
|
||||
wrap_width,
|
||||
multiline,
|
||||
password,
|
||||
default_cursor_range,
|
||||
char_limit,
|
||||
event_filter,
|
||||
return_key,
|
||||
);
|
||||
|
||||
if changed {
|
||||
text_changed = true;
|
||||
}
|
||||
cursor_range = Some(new_cursor_range);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 mut get_galley = None;
|
||||
let inner_rect_id = Id::new("text_edit_rect");
|
||||
let atom_response = {
|
||||
let any_shrink = hint_text.any_shrink();
|
||||
// Ideally we could just do `let mut atoms = prefix` here, but that won't compile
|
||||
// but due to servo/rust-smallvec#146 (also see the comment below).
|
||||
let mut atoms: Atoms<'_> = Atoms::new(());
|
||||
|
||||
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
|
||||
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
|
||||
for atom in prefix {
|
||||
atoms.push_right(atom);
|
||||
}
|
||||
|
||||
if text.as_str().is_empty() && !hint_text.is_empty() {
|
||||
// Add hint_text (if any):
|
||||
let mut shrunk = any_shrink;
|
||||
let mut first = true;
|
||||
|
||||
// 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()));
|
||||
|
||||
for mut atom in hint_text {
|
||||
if !shrunk && matches!(atom.kind, AtomKind::Text(_)) {
|
||||
// elide the hint_text if needed
|
||||
atom = atom.atom_shrink(true);
|
||||
shrunk = true;
|
||||
}
|
||||
|
||||
if first {
|
||||
// The first atom in the hint text gets inner_rect_id, so we can know
|
||||
// where to paint the cursor
|
||||
atom = atom.atom_id(inner_rect_id);
|
||||
first = false;
|
||||
}
|
||||
|
||||
// The hint text should be shown left top instead of centered (important for
|
||||
// multi line text edits)
|
||||
atoms.push_right(atom.atom_align(Align2::LEFT_TOP));
|
||||
}
|
||||
|
||||
// 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 galley = layouter(ui, text, available_width);
|
||||
|
||||
// We can't update the galley immediately here, since it would show both hint text
|
||||
// and the newly typed letter. So we pass a clone instead, and accept having a frame
|
||||
// delay on the very first keystroke.
|
||||
let mut galley_clone = Arc::clone(&galley);
|
||||
handle_events(ui, &mut galley_clone, layouter, available_width, text);
|
||||
|
||||
get_galley = Some(galley);
|
||||
} else {
|
||||
// We need a closure here, so we can calculate the galley based on the available
|
||||
// width (after adding suffix and prefix), for correct wrapping in multi line text
|
||||
// edits
|
||||
atoms.push_right(
|
||||
AtomKind::closure(|ui, args| {
|
||||
let mut galley = layouter(ui, text, args.available_size.x);
|
||||
|
||||
// Handling events here allows us to update the galley immediately on
|
||||
// keystrokes, avoiding frame delays, and ensuring the scroll_to within
|
||||
// ScrollAreas works correctly.
|
||||
handle_events(ui, &mut galley, layouter, args.available_size.x, text);
|
||||
|
||||
let intrinsic_size = galley.intrinsic_size();
|
||||
let mut size = galley.size();
|
||||
size.y = size.y.at_least(min_inner_height);
|
||||
if clip_text {
|
||||
size.x = size.x.at_most(args.available_size.x);
|
||||
}
|
||||
|
||||
// We paint the galley later, so we can do clipping and offsetting
|
||||
get_galley = Some(galley);
|
||||
IntoSizedResult {
|
||||
intrinsic_size,
|
||||
sized: SizedAtomKind::Empty { size: Some(size) },
|
||||
}
|
||||
})
|
||||
.atom_id(inner_rect_id)
|
||||
.atom_shrink(clip_text),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the suffix is always right-aligned
|
||||
if !suffix.is_empty() {
|
||||
atoms.push_right(Atom::grow());
|
||||
}
|
||||
|
||||
// TODO(servo/rust-smallvec#146): Use extend_right instead of the loop once we have
|
||||
// smallvec 2.0. Using `extend_right` here won't compile, due to lifetime issues.
|
||||
for atom in suffix {
|
||||
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
|
||||
let wrap_mode = if multiline {
|
||||
TextWrapMode::Wrap
|
||||
} else {
|
||||
TextWrapMode::Truncate
|
||||
};
|
||||
|
||||
let mut allocated = AtomLayout::new(atoms)
|
||||
.id(id)
|
||||
.min_size(Vec2::new(allocate_width, min_height))
|
||||
.max_width(allocate_width)
|
||||
.sense(sense)
|
||||
.frame(frame)
|
||||
.align2(Align2::LEFT_TOP)
|
||||
.wrap_mode(wrap_mode)
|
||||
.allocate(ui);
|
||||
|
||||
allocated.frame = if !custom_frame {
|
||||
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(stroke.width as i8))
|
||||
.stroke(stroke)
|
||||
} else {
|
||||
allocated.frame
|
||||
};
|
||||
|
||||
allocated.paint(ui)
|
||||
};
|
||||
|
||||
let inner_rect = atom_response.rect(inner_rect_id).unwrap_or(Rect::ZERO);
|
||||
let mut response = atom_response.response;
|
||||
|
||||
// Our atom closure was now called, so the galley should always be available here
|
||||
let mut galley = get_galley.expect("Galley should be available here");
|
||||
|
||||
// Don't send `OutputEvent::Clicked` when a user presses the space bar
|
||||
response.flags -= response::Flags::FAKE_PRIMARY_CLICKED;
|
||||
let text_clip_rect = rect;
|
||||
let text_clip_rect = inner_rect;
|
||||
let painter = ui.painter_at(text_clip_rect.expand(1.0)); // expand to avoid clipping cursor
|
||||
|
||||
if interactive && let Some(pointer_pos) = response.interact_pointer_pos() {
|
||||
@@ -581,19 +735,19 @@ impl TextEdit<'_> {
|
||||
// TODO(emilk): drag selected text to either move or clone (ctrl on windows, alt on mac)
|
||||
|
||||
let cursor_at_pointer =
|
||||
galley.cursor_from_pos(pointer_pos - rect.min + state.text_offset);
|
||||
galley.cursor_from_pos(pointer_pos - inner_rect.min + state.text_offset);
|
||||
|
||||
if ui.visuals().text_cursor.preview
|
||||
&& response.hovered()
|
||||
&& ui.input(|i| i.pointer.is_moving())
|
||||
{
|
||||
// text cursor preview:
|
||||
let cursor_rect = TSTransform::from_translation(rect.min.to_vec2())
|
||||
let cursor_rect = TSTransform::from_translation(inner_rect.min.to_vec2())
|
||||
* cursor_rect(&galley, &cursor_at_pointer, row_height);
|
||||
text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect);
|
||||
}
|
||||
|
||||
let is_being_dragged = ui.ctx().is_being_dragged(response.id);
|
||||
let is_being_dragged = ui.is_being_dragged(response.id);
|
||||
let did_interact = state.cursor.pointer_interaction(
|
||||
ui,
|
||||
&response,
|
||||
@@ -613,44 +767,15 @@ impl TextEdit<'_> {
|
||||
ui.set_cursor_icon(CursorIcon::Text);
|
||||
}
|
||||
|
||||
let mut cursor_range = None;
|
||||
let prev_cursor_range = state.cursor.range(&galley);
|
||||
if interactive && ui.memory(|mem| mem.has_focus(id)) {
|
||||
ui.memory_mut(|mem| mem.set_focus_lock_filter(id, event_filter));
|
||||
|
||||
let default_cursor_range = if cursor_at_end {
|
||||
CCursorRange::one(galley.end())
|
||||
} else {
|
||||
CCursorRange::default()
|
||||
};
|
||||
|
||||
let (changed, new_cursor_range) = events(
|
||||
ui,
|
||||
&mut state,
|
||||
text,
|
||||
&mut galley,
|
||||
layouter,
|
||||
id,
|
||||
wrap_width,
|
||||
multiline,
|
||||
password,
|
||||
default_cursor_range,
|
||||
char_limit,
|
||||
event_filter,
|
||||
return_key,
|
||||
);
|
||||
|
||||
if changed {
|
||||
response.mark_changed();
|
||||
}
|
||||
cursor_range = Some(new_cursor_range);
|
||||
if text_changed {
|
||||
response.mark_changed();
|
||||
}
|
||||
|
||||
let mut galley_pos = align
|
||||
.align_size_within_rect(galley.size(), rect)
|
||||
.intersect(rect) // limit pos to the response rect area
|
||||
.align_size_within_rect(galley.size(), inner_rect)
|
||||
.intersect(inner_rect) // limit pos to the response rect area
|
||||
.min;
|
||||
let align_offset = rect.left_top() - galley_pos;
|
||||
let align_offset = inner_rect.left_top() - galley_pos;
|
||||
|
||||
// Visual clipping for singleline text editor with text larger than width
|
||||
if clip_text && align_offset.x == 0.0 {
|
||||
@@ -660,18 +785,18 @@ impl TextEdit<'_> {
|
||||
};
|
||||
|
||||
let mut offset_x = state.text_offset.x;
|
||||
let visible_range = offset_x..=offset_x + desired_inner_size.x;
|
||||
let visible_range = offset_x..=offset_x + inner_rect.width();
|
||||
|
||||
if !visible_range.contains(&cursor_pos) {
|
||||
if cursor_pos < *visible_range.start() {
|
||||
offset_x = cursor_pos;
|
||||
} else {
|
||||
offset_x = cursor_pos - desired_inner_size.x;
|
||||
offset_x = cursor_pos - inner_rect.width();
|
||||
}
|
||||
}
|
||||
|
||||
offset_x = offset_x
|
||||
.at_most(galley.size().x - desired_inner_size.x)
|
||||
.at_most(galley.size().x - inner_rect.width())
|
||||
.at_least(0.0);
|
||||
|
||||
state.text_offset = vec2(offset_x, align_offset.y);
|
||||
@@ -688,32 +813,7 @@ impl TextEdit<'_> {
|
||||
false
|
||||
};
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
if text.as_str().is_empty() && !hint_text.is_empty() {
|
||||
let hint_text_color = ui.visuals().weak_text_color();
|
||||
let hint_text_font_id = hint_text_font.unwrap_or_else(|| font_id.into());
|
||||
let galley = if multiline {
|
||||
hint_text.into_galley(
|
||||
ui,
|
||||
Some(TextWrapMode::Wrap),
|
||||
desired_inner_size.x,
|
||||
hint_text_font_id,
|
||||
)
|
||||
} else {
|
||||
hint_text.into_galley(
|
||||
ui,
|
||||
Some(TextWrapMode::Extend),
|
||||
f32::INFINITY,
|
||||
hint_text_font_id,
|
||||
)
|
||||
};
|
||||
let galley_pos = align
|
||||
.align_size_within_rect(galley.size(), rect)
|
||||
.intersect(rect)
|
||||
.min;
|
||||
painter.galley(galley_pos, galley, hint_text_color);
|
||||
}
|
||||
|
||||
if ui.is_rect_visible(inner_rect) {
|
||||
let has_focus = ui.memory(|mem| mem.has_focus(id));
|
||||
|
||||
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
|
||||
@@ -721,44 +821,6 @@ impl TextEdit<'_> {
|
||||
paint_text_selection(&mut galley, ui.visuals(), &cursor_range, None);
|
||||
}
|
||||
|
||||
// Allocate additional space if edits were made this frame that changed the size. This is important so that,
|
||||
// if there's a ScrollArea, it can properly scroll to the cursor.
|
||||
// Condition `!clip_text` is important to avoid breaking layout for `TextEdit::singleline` (PR #5640)
|
||||
if !clip_text
|
||||
&& let extra_size = galley.size() - rect.size()
|
||||
&& (extra_size.x > 0.0 || extra_size.y > 0.0)
|
||||
{
|
||||
match ui.layout().main_dir() {
|
||||
crate::Direction::LeftToRight | crate::Direction::TopDown => {
|
||||
ui.allocate_rect(
|
||||
Rect::from_min_size(outer_rect.max, extra_size),
|
||||
Sense::hover(),
|
||||
);
|
||||
}
|
||||
crate::Direction::RightToLeft => {
|
||||
ui.allocate_rect(
|
||||
Rect::from_min_size(
|
||||
emath::pos2(outer_rect.min.x - extra_size.x, outer_rect.max.y),
|
||||
extra_size,
|
||||
),
|
||||
Sense::hover(),
|
||||
);
|
||||
}
|
||||
crate::Direction::BottomUp => {
|
||||
ui.allocate_rect(
|
||||
Rect::from_min_size(
|
||||
emath::pos2(outer_rect.min.x, outer_rect.max.y - extra_size.y),
|
||||
extra_size,
|
||||
),
|
||||
Sense::hover(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Avoid an ID shift during this pass if the textedit grow
|
||||
ui.skip_ahead_auto_ids(1);
|
||||
}
|
||||
|
||||
painter.galley(galley_pos, Arc::clone(&galley), text_color);
|
||||
|
||||
if has_focus && let Some(cursor_range) = state.cursor.range(&galley) {
|
||||
@@ -767,7 +829,7 @@ impl TextEdit<'_> {
|
||||
|
||||
if response.changed() || selection_changed {
|
||||
// Scroll to keep primary cursor in view:
|
||||
ui.scroll_to_rect(primary_cursor_rect + margin, None);
|
||||
ui.scroll_to_rect(primary_cursor_rect, None);
|
||||
}
|
||||
|
||||
if text.is_mutable() && interactive {
|
||||
@@ -796,9 +858,9 @@ impl TextEdit<'_> {
|
||||
.layer_transform_to_global(ui.layer_id())
|
||||
.unwrap_or_default();
|
||||
|
||||
ui.ctx().output_mut(|o| {
|
||||
ui.output_mut(|o| {
|
||||
o.ime = Some(crate::output::IMEOutput {
|
||||
rect: to_global * rect,
|
||||
rect: to_global * inner_rect,
|
||||
cursor_rect: to_global * primary_cursor_rect,
|
||||
});
|
||||
});
|
||||
@@ -846,24 +908,22 @@ impl TextEdit<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let role = if password {
|
||||
accesskit::Role::PasswordInput
|
||||
} else if multiline {
|
||||
accesskit::Role::MultilineTextInput
|
||||
} else {
|
||||
accesskit::Role::TextInput
|
||||
};
|
||||
let role = if password {
|
||||
accesskit::Role::PasswordInput
|
||||
} else if multiline {
|
||||
accesskit::Role::MultilineTextInput
|
||||
} else {
|
||||
accesskit::Role::TextInput
|
||||
};
|
||||
|
||||
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
|
||||
ui.ctx(),
|
||||
id,
|
||||
cursor_range,
|
||||
role,
|
||||
TSTransform::from_translation(galley_pos.to_vec2()),
|
||||
&galley,
|
||||
);
|
||||
}
|
||||
crate::text_selection::accesskit_text::update_accesskit_for_text_widget(
|
||||
ui.ctx(),
|
||||
id,
|
||||
cursor_range,
|
||||
role,
|
||||
TSTransform::from_translation(galley_pos.to_vec2()),
|
||||
&galley,
|
||||
);
|
||||
|
||||
TextEditOutput {
|
||||
response,
|
||||
@@ -911,7 +971,7 @@ fn events(
|
||||
event_filter: EventFilter,
|
||||
return_key: Option<KeyboardShortcut>,
|
||||
) -> (bool, CCursorRange) {
|
||||
let os = ui.ctx().os();
|
||||
let os = ui.os();
|
||||
|
||||
let mut cursor_range = state.cursor.range(galley).unwrap_or(default_cursor_range);
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c8b23a5286e5d2dbd8d3eddac6583d981152bd791f74edfa5c712a610f795256
|
||||
size 96759
|
||||
oid sha256:73592be3cb5e2bbc1de870050b913b307e31c05df339b2fd78e9ce38c05f4cd2
|
||||
size 96758
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a53262cf5d8507d8eeae8c968767cef462b727879245085673982b850a6da670
|
||||
oid sha256:dfccdafb7e96db488bb5bb8c0a7d25f70e63d900d6b1c2280d218aac0e70e4c4
|
||||
size 26977
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a7601584308bf60820506f842569a3c1daf3c15fa6e715f6b9386b5112dcc92f
|
||||
size 76076
|
||||
oid sha256:b849adf0ff9a06e1f7bfa22ef32b13224c7e62429cf675286110729156434d12
|
||||
size 76531
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3fc2793506ec483c7f124b6206fb18ffb73bec29746f2d9bb5145042ddc45016
|
||||
size 114410
|
||||
oid sha256:8c630df841e98043132b920338793745549116890c1bc52d8d10b0def896a1e6
|
||||
size 114409
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:718203d31d8b027a7718a66c4712cf1e17b9aea2e870d755bd2c0c346529d4f4
|
||||
oid sha256:f5e9d645e1decf7624bc8f031b5e28213e64fc5f568dd3eeb1768a57fcb988c3
|
||||
size 21814
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6af5adc42544171c6d85e190c853aca06784c131a373a693a6f7069d4cf1a404
|
||||
size 13698
|
||||
oid sha256:99fa5a5cb10c7d277eafb258af6019eda24a3c96075a50db321f52a521dede92
|
||||
size 13700
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2e8e03c2a42e195e6489659053aecb78755d3c218558cb2e9339fa7b6db59405
|
||||
size 35875
|
||||
oid sha256:1cc61413bcce62cc8e0a55460a974bb56ac40936cd2e5512c4a0e0c521eaaae4
|
||||
size 35874
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ad22ea6b6e69fd71416fdae76cbd142d279f8f562e74b77e63b3989be187c57c
|
||||
size 484631
|
||||
oid sha256:9c81d5a5915dac60297293b90cd3fc0d188a9335e99b318996e3e2934de7bee1
|
||||
size 483497
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::epaint::Shape;
|
||||
use egui::{Align, Color32, Image, Label, Layout, RichText, Sense, TextWrapMode, include_image};
|
||||
use egui::style::ScrollAnimation;
|
||||
use egui::{
|
||||
Align, Color32, Image, Label, Layout, RichText, ScrollArea, Sense, TextWrapMode, include_image,
|
||||
};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
|
||||
@@ -63,6 +66,67 @@ fn text_edit_rtl() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_delay() {
|
||||
let mut text = String::new();
|
||||
let mut harness = Harness::builder().with_size((200.0, 50.0)).build_ui(|ui| {
|
||||
ui.style_mut().scroll_animation = ScrollAnimation::none();
|
||||
ui.add(egui::TextEdit::singleline(&mut text).hint_text("Write something"));
|
||||
});
|
||||
|
||||
harness.get_by_role(Role::TextInput).focus();
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_delay_0_empty");
|
||||
|
||||
harness.get_by_role(Role::TextInput).type_text("h");
|
||||
|
||||
// When the text is empty, and we show the hint text, there is a frame delay.
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_delay_1_h_invisible");
|
||||
|
||||
// Now it should be visible
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_delay_2_h_visible");
|
||||
|
||||
harness.get_by_role(Role::TextInput).type_text("i");
|
||||
|
||||
// The "i" should immediately be visible without a delay
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_delay_3_i_visible");
|
||||
|
||||
// The next frame should exactly match the previous one
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_delay_4_i_visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_scroll() {
|
||||
let mut text = "1\n2\n3\n4\n".to_owned();
|
||||
let mut harness = Harness::builder().build_ui(|ui| {
|
||||
ScrollArea::vertical().max_height(40.0).show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut text)
|
||||
.desired_rows(2)
|
||||
.hint_text("Write something"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
harness.fit_contents();
|
||||
|
||||
harness.get_by_role(Role::MultilineTextInput).focus();
|
||||
harness.step();
|
||||
harness.snapshot("text_edit_scroll_0_focus");
|
||||
|
||||
harness
|
||||
.get_by_role(Role::MultilineTextInput)
|
||||
.type_text("5\n");
|
||||
|
||||
// When the text is empty, and we show the hint text, there is a frame delay.
|
||||
harness.run();
|
||||
harness.snapshot("text_edit_scroll_1_5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combobox_should_have_value() {
|
||||
let harness = Harness::new_ui(|ui| {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:30de3e9f9645206e33fa1edd841b48228e154d0ceae962c64c060a66eecd73ba
|
||||
size 220452
|
||||
oid sha256:29363b37f1260f9f39edf9ba873f4c33c0d8a8b6670f6fc178459019539ae7e3
|
||||
size 220588
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a9f36b8623d2d9c35e337e973f547166f62a5daae757c462b1482babdd42c941
|
||||
size 383051
|
||||
oid sha256:94186c0b9331fd0d13284126f4f5e92e66014105fb6533422516d4fbe765e4c7
|
||||
size 372041
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1a2734644b2fbb6f42ddab6c65a1f5d073f1f002900bbd814c1edb6184e0a9c0
|
||||
size 362521
|
||||
oid sha256:8ff058ef716689c309ae9806aaf08fb64eca545ef8f92ce89e1f8e9b7b7733bc
|
||||
size 330200
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cf00e99dbfdf7497688955feb8c417fab0a366588d92182eccee775abade5179
|
||||
size 361876
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cf62a248bcec1054cbd97251e6fc429972ef2318c24b9a56698d7c80115aa57e
|
||||
size 2262
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ef70c95f7e171984f992e1b9366b4a0fe11a4871746cb8cfaa8ee263e59de702
|
||||
size 2272
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ab120260212d0f41d2956ea2d679cfed648cb188badcca7fa82e0dec9c87ec1a
|
||||
size 714
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2ea9d6bef26f1dbd7dc272b4331012ad6fd1a43fded150d33ba4762d404dbde
|
||||
size 775
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2ea9d6bef26f1dbd7dc272b4331012ad6fd1a43fded150d33ba4762d404dbde
|
||||
size 775
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce357224c2e1cf32f96b3d075dc070c4d14e9aaca1b8165d0ba98603dff19c1b
|
||||
size 2324
|
||||
oid sha256:ed3665dfb232b8f0b1483802bfafb4605e8361d7eb977de5a58862e52ab724fa
|
||||
size 2296
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d91c715ac66be329cac42ff7c7726348b0ac79d897c414bbde26bb0115781577
|
||||
size 2289
|
||||
oid sha256:5cfdd6255aba92f0253571bde4f22afc8e0fb5fe3cb882946459d623c0a5d89c
|
||||
size 2982
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:81620caad6d420f3bd0f224e5b07a02960a42436208a98d3aa012e5db61a743a
|
||||
size 1510
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f915eafb6490ff456c5b0a7c74c38ef143262bdf74a0c6561b9cf6ee66a679ea
|
||||
size 1501
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71daf8a33d277075012bf1130d7820574fe0286080154810d8d398c005a65127
|
||||
size 9037
|
||||
oid sha256:60ad2d88535977244ac0fa153700489b454a582af2829dc2f41a531943a21d7a
|
||||
size 9079
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8d259d113aa23089992b04f19e71c743272dda3fc9baa9612565158f15ace57e
|
||||
size 8159
|
||||
oid sha256:7e1fb3fb0a00a447906aa205c27aa496dcb3d79e98aadf6092811a0514efb5a0
|
||||
size 8127
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a1e56f1b6970c14830d8869f4d8cacfed821ec2b3aab7033b1bfd213a864da79
|
||||
size 10959
|
||||
oid sha256:077e7de9fdaaa222ee75f6ad620967fb1e29da37f60407d584be7141e9d0badd
|
||||
size 10143
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:493649ea09351835147aa6cc800858939dd44beafe37adc488b63b291d58e3b3
|
||||
size 10302
|
||||
oid sha256:b022e27d7275764df45039abd26f80d69af40fb18bec98cca85565850df859ae
|
||||
size 8838
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:337dcbf0b3a344c6cadaf9500376a627739e19e9c47b5da23786c98c612ef4dc
|
||||
size 10028
|
||||
@@ -122,6 +122,18 @@ fn widget_tests() {
|
||||
},
|
||||
&mut results,
|
||||
);
|
||||
test_widget(
|
||||
"text_edit_prefix_suffix",
|
||||
|ui| {
|
||||
ui.spacing_mut().text_edit_width = 45.0;
|
||||
TextEdit::singleline(&mut "Hello World".to_owned())
|
||||
.prefix("🔎")
|
||||
.suffix("!")
|
||||
.clip_text(true)
|
||||
.ui(ui)
|
||||
},
|
||||
&mut results,
|
||||
);
|
||||
|
||||
test_widget(
|
||||
"slider",
|
||||
|
||||
Reference in New Issue
Block a user