From 5278a73bca4a2496365affb9058cf22b6224c6ca Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 14 Apr 2026 13:13:59 +0200 Subject: [PATCH] Fix centered & right aligned `TextEdit` (#8082) A couple improvements to centered and right-aligned text edits: - Fix text selection in centered and right aligned text edits (ironically, this broke in #8076) - Fix cursor movement in centered and right aligned text edits (horizontal cursor position will be retained on vertical movement) - Multiline text edit exceeding available width if there are atoms - Added atoms & alignment options to text edit demo - Improve how vertical_align and horizontal_align are applied - Textedit atom is grow now, removing the need for the extra seperate grow atom - This allows us to apply the `align` on the text edit atom instead of the whole AtomLayout - Fixes https://github.com/emilk/egui/pull/8022 - Fixes https://github.com/emilk/egui/issues/7999 --- crates/egui/src/widgets/text_edit/builder.rs | 36 +++++++++------- crates/egui_demo_lib/src/demo/text_edit.rs | 42 ++++++++++++++++++- .../tests/snapshots/demos/TextEdit.png | 4 +- crates/epaint/src/text/text_layout_types.rs | 6 ++- .../layout/text_edit_prefix_suffix.png | 4 +- .../tests/snapshots/text_edit_halign.png | 4 +- .../visuals/text_edit_prefix_suffix.png | 4 +- 7 files changed, 74 insertions(+), 26 deletions(-) diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index ef668a02e..0053c8ac5 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -4,8 +4,8 @@ use emath::{Rect, TSTransform}; use epaint::text::{Galley, LayoutJob, TextWrapMode, cursor::CCursor}; use crate::{ - Align, Align2, Atom, AtomExt as _, AtomKind, AtomLayout, Atoms, Color32, Context, CursorIcon, - Event, EventFilter, FontSelection, Frame, Id, ImeEvent, IntoAtoms, IntoSizedResult, Key, + Align, Align2, 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, @@ -477,11 +477,12 @@ impl TextEdit<'_> { 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 layout_job = if multiline { + let mut layout_job = if multiline { LayoutJob::simple(text, font_id_clone.clone(), text_color, wrap_width) } else { LayoutJob::simple_singleline(text, font_id_clone.clone(), text_color) }; + layout_job.halign = align.x(); ui.fonts_mut(|f| f.layout_job(layout_job)) }; @@ -588,6 +589,7 @@ impl TextEdit<'_> { if !shrunk && matches!(atom.kind, AtomKind::Text(_)) { // elide the hint_text if needed atom = atom.atom_shrink(true); + atom = atom.atom_grow(true); shrunk = true; } @@ -616,6 +618,11 @@ impl TextEdit<'_> { get_galley = Some(galley); } else { + // We need to shrink when clip_text, so that we don't exceed the available size + // and thus clip. We also need to shrink in multi line text edits, so text can + // wrap appropriately. + let should_shrink = clip_text || multiline; + // 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 @@ -642,16 +649,13 @@ impl TextEdit<'_> { sized: SizedAtomKind::Empty { size: Some(size) }, } }) + .atom_grow(true) + .atom_align(self.align) .atom_id(inner_rect_id) - .atom_shrink(clip_text), + .atom_shrink(should_shrink), ); } - // 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 { @@ -676,7 +680,7 @@ impl TextEdit<'_> { .max_width(allocate_width) .sense(sense) .frame(frame) - .align2(Align2::LEFT_TOP) + .align2(align) .wrap_mode(wrap_mode) .allocate(ui); @@ -737,16 +741,18 @@ 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 - inner_rect.min + state.text_offset); + let cursor_at_pointer = galley.cursor_from_pos( + pointer_pos - inner_rect.min + state.text_offset + vec2(galley.rect.left(), 0.0), + ); if ui.visuals().text_cursor.preview && response.hovered() && ui.input(|i| i.pointer.is_moving()) { // text cursor preview: - let cursor_rect = TSTransform::from_translation(inner_rect.min.to_vec2()) - * cursor_rect(&galley, &cursor_at_pointer, row_height); + let cursor_rect = TSTransform::from_translation( + inner_rect.min.to_vec2() - vec2(galley.rect.left(), 0.0), + ) * cursor_rect(&galley, &cursor_at_pointer, row_height); text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect); } @@ -832,7 +838,7 @@ impl TextEdit<'_> { if has_focus && let Some(cursor_range) = state.cursor.range(&galley) { let primary_cursor_rect = cursor_rect(&galley, &cursor_range.primary, row_height) - .translate(galley_pos.to_vec2()); + .translate(galley_pos.to_vec2() - vec2(galley.rect.left(), 0.0)); if response.changed() || selection_changed { // Scroll to keep primary cursor in view: diff --git a/crates/egui_demo_lib/src/demo/text_edit.rs b/crates/egui_demo_lib/src/demo/text_edit.rs index 3ec53a523..8fd42da11 100644 --- a/crates/egui_demo_lib/src/demo/text_edit.rs +++ b/crates/egui_demo_lib/src/demo/text_edit.rs @@ -1,15 +1,21 @@ +use egui::{Align, Align2, AtomExt as _}; + /// Showcase [`egui::TextEdit`]. #[derive(PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct TextEditDemo { pub text: String, + halign: egui::Align, + valign: egui::Align, } impl Default for TextEditDemo { fn default() -> Self { Self { text: "Edit this text".to_owned(), + halign: egui::Align::LEFT, + valign: egui::Align::TOP, } } } @@ -37,7 +43,11 @@ impl crate::View for TextEditDemo { ui.add(crate::egui_github_link_file!()); }); - let Self { text } = self; + let Self { + text, + halign, + valign, + } = self; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; @@ -46,10 +56,40 @@ impl crate::View for TextEditDemo { ui.label("."); }); + ui.horizontal(|ui| { + ui.label("Horizontal align:"); + ui.selectable_value(halign, egui::Align::LEFT, "Left"); + ui.selectable_value(halign, egui::Align::Center, "Center"); + ui.selectable_value(halign, egui::Align::RIGHT, "Right"); + }); + ui.horizontal(|ui| { + ui.label("Vertical align:"); + ui.selectable_value(valign, egui::Align::TOP, "Top"); + ui.selectable_value(valign, egui::Align::Center, "Center"); + ui.selectable_value(valign, egui::Align::BOTTOM, "Bottom"); + }); + + let clear_id = egui::Id::new("clear_button"); + let clear_size = egui::Vec2::splat(ui.spacing().interact_size.y); + let output = egui::TextEdit::multiline(text) .hint_text("Type something!") + // Atoms are centered by default, so we need to pass the right align here: + .prefix("🔎".atom_align(Align2([Align::LEFT, *valign]))) + .suffix( + egui::Atom::custom(clear_id, clear_size) + .atom_align(Align2([Align::RIGHT, *valign])), + ) + .horizontal_align(*halign) + .vertical_align(*valign) .show(ui); + if let Some(rect) = output.response.rect(clear_id) + && ui.place(rect, egui::Button::new("❌")).clicked() + { + text.clear(); + } + ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label("Selected text: "); diff --git a/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png index 6b1a9946b..839a15faa 100644 --- a/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png +++ b/crates/egui_demo_lib/tests/snapshots/demos/TextEdit.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f5e9d645e1decf7624bc8f031b5e28213e64fc5f568dd3eeb1768a57fcb988c3 -size 21814 +oid sha256:94c4af5715992f4dbb5bbec6ce67eec1e2f66cfc078a3e704ec386bdb482cac4 +size 30064 diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 481e3ebbf..fc987890d 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -1244,7 +1244,8 @@ impl Galley { let new_layout_cursor = { // keep same X coord - let column = self.rows[new_row].char_at(h_pos); + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); LayoutCursor { row: new_row, column, @@ -1266,7 +1267,8 @@ impl Galley { let new_layout_cursor = { // keep same X coord - let column = self.rows[new_row].char_at(h_pos); + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); LayoutCursor { row: new_row, column, diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_prefix_suffix.png b/tests/egui_tests/tests/snapshots/layout/text_edit_prefix_suffix.png index bdcab38f2..642a8c5fd 100644 --- a/tests/egui_tests/tests/snapshots/layout/text_edit_prefix_suffix.png +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_prefix_suffix.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf00e99dbfdf7497688955feb8c417fab0a366588d92182eccee775abade5179 -size 361876 +oid sha256:a9f298f8ea6692e7ccbddbe182a91824ce262913d50e9a7df104a6c63d39d8a0 +size 372564 diff --git a/tests/egui_tests/tests/snapshots/text_edit_halign.png b/tests/egui_tests/tests/snapshots/text_edit_halign.png index 29546a036..2f81509e6 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:502607c803b884e4e1640d39c97b03b0a40df93c2da328f889168e386f837f36 -size 13261 +oid sha256:48114ad6d116fb9288ce9fe3b173017bda317f69753e7ac03a090b8d02d6cb4d +size 13258 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_prefix_suffix.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_prefix_suffix.png index d27f6f8c4..e00868b1f 100644 --- a/tests/egui_tests/tests/snapshots/visuals/text_edit_prefix_suffix.png +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_prefix_suffix.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:337dcbf0b3a344c6cadaf9500376a627739e19e9c47b5da23786c98c612ef4dc -size 10028 +oid sha256:a37ed30425967301ffa7dda3fdc8f316dfd7f4665c731b17778e3e5942783e81 +size 10534