A click in a text field's padding focuses its editor (#12302) (#12400)

* A click in a text field's padding focuses its editor (#12302)

The shared text field's box sensed hover only, so a click in the outer
point of its 6 pt sides (past egui's 5 pt reach to the editor) did
nothing. The box now senses clicks (not focus, so Tab still stops on the
editor alone); since the editor sits on top, only padding clicks reach
it, and they focus the editor with the caret at the nearest point inside.

* Text field: the editor covers the box, so a padding click is its own (#12302)

The box sensing clicks and placing the caret itself broke on a scrolled
value: pressing the padding takes the focus from the editor, which then
scrolls back to the start before the click lands, so the caret went to
the wrong character. The editor now fills the box but the trailing space,
with the padding as its frame's inner margin, so egui handles a padding
click like any other: focus, caret, scroll, drag and double-click.

Tests from review: corner clicks (fail on main), an empty field, a
scrolled value, a disabled field, the trailing popup arrow keeping its
click, and the editor-click test no longer depends on glyph widths.
This commit is contained in:
RickiNano authored and GitHub committed 2026-11-10 20:08:39 +01:00
1 parent 65bd584259
commit 4258fc838f
3 files changed
+134 -7

No files matched your search

@@ -153,7 +153,8 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() {
.map(|n| n.rect())
.find(|r| r.top() >= header.bottom() && r.left() >= header.left() - 1.0)
.expect("editable family field");
let mut family = name.expand2(egui::vec2(6.0, 2.0));
// The editor covers the field's box but the popup arrow's space (#12302).
let mut family = name;
family.max.x += crate::widgets::POPUP_ARROW_W;
assert!((family.left() - header.left()).abs() <= 2.0 && (family.right() - header.right()).abs() <= 2.0, "{what}: {family:?} vs {header:?}");
let combos: Vec<Rect> = h
+123 -1
View File
@@ -334,6 +334,128 @@ fn text_field_bounds_an_oversized_existing_value() {
assert!(h.state().chars().count() < super::TEXT_FIELD_LIMIT, "an edit keeps the bounded copy");
}
/// A text field's text, its box, and whether its editor has the focus.
type FieldState = (String, egui::Rect, bool);
/// A harness drawing one 140 pt text field holding `text`, in a `Ui` that is `enabled` or not.
fn text_field_harness(text: &str, enabled: bool) -> Harness<'static, FieldState> {
let id = egui::Id::unique("tf");
let mut h = Harness::new_ui_state(
move |ui, s: &mut FieldState| {
let field = ui.add_enabled_ui(enabled, |ui| drop(super::text_field(ui, id, &mut s.0, "", 140.0, false).accessible_name("Value")));
s.1 = field.response.rect;
s.2 = ui.memory(|m| m.has_focus(id));
},
(text.to_owned(), egui::Rect::NOTHING, false),
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run();
assert_eq!(h.state().1.size(), egui::vec2(140.0, super::TEXT_FIELD_H), "the state holds the field's box");
h
}
/// Clicks a field holding `text` at `at(its box)` and types "X": the text after that, and
/// whether the click focused the editor.
fn click_text_field_then_type(text: &str, enabled: bool, at: impl Fn(egui::Rect) -> egui::Pos2) -> (String, bool) {
let mut h = text_field_harness(text, enabled);
let pos = at(h.state().1);
click_at(&mut h, pos, egui::PointerButton::Primary);
let focused = h.state().2;
h.event(egui::Event::Text("X".into()));
h.run();
(h.state().0.clone(), focused)
}
/// #12302: a click in a text field's padding focuses its editor with the caret at the nearest
/// point inside it: the start from the left, the end from the right. Before the fix only a click
/// within egui's `interact_radius` (5 pt) of the editor reached it, so the left and right clicks
/// sit half a point in from the box's edge (5.5 pt out, on the 6 pt sides) and the corner clicks
/// are farther still diagonally, clamped into the editor in both axes.
#[test]
fn text_field_padding_click_focuses_the_editor() {
let click = |at: fn(egui::Rect) -> egui::Pos2| click_text_field_then_type("hello", true, at);
assert_eq!(click(|r| egui::pos2(r.left() + 0.5, r.center().y)), ("Xhello".to_owned(), true), "left padding");
assert_eq!(click(|r| egui::pos2(r.right() - 0.5, r.center().y)), ("helloX".to_owned(), true), "right padding");
assert_eq!(click(|r| r.left_top() + egui::vec2(0.5, 0.5)), ("Xhello".to_owned(), true), "top-left corner");
assert_eq!(click(|r| r.right_bottom() - egui::vec2(0.5, 0.5)), ("helloX".to_owned(), true), "bottom-right corner");
// Above and below the text, the caret goes where a click straight down (or up) into the
// editor puts it, in the middle of "hello" here.
let inside = click(|r| egui::pos2(r.left() + 20.0, r.center().y));
assert_eq!(click(|r| egui::pos2(r.left() + 20.0, r.top() + 0.5)), inside, "top padding");
assert_eq!(click(|r| egui::pos2(r.left() + 20.0, r.bottom() - 0.5)), inside, "bottom padding");
}
/// #12302: a padding click on an empty field focuses it, with the caret at the only place it
/// can go.
#[test]
fn text_field_padding_click_focuses_an_empty_field() {
for (side, at) in [("left", 0.5), ("right", 139.5)] {
let clicked = click_text_field_then_type("", true, |r| egui::pos2(r.left() + at, r.center().y));
assert_eq!(clicked, ("X".to_owned(), true), "{side} padding");
}
}
/// #12302: in a value wider than the field, scrolled to its end while being edited, a click in
/// the right padding puts the caret at the end of the text in view, which is the value's end.
#[test]
fn text_field_padding_click_follows_a_scrolled_value() {
let long = "abcdefghij".repeat(10);
let mut h = text_field_harness(&long, true);
let r = h.state().1;
click_at(&mut h, egui::pos2(r.left() + 0.5, r.center().y), egui::PointerButton::Primary);
h.event(egui::Event::Key { key: Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run();
click_at(&mut h, egui::pos2(r.right() - 0.5, r.center().y), egui::PointerButton::Primary);
h.event(egui::Event::Text("X".into()));
h.run();
assert_eq!(h.state().0, format!("{long}X"));
}
/// A disabled field's padding takes no click: the editor stays unfocused and typing changes
/// nothing.
#[test]
fn disabled_text_field_padding_click_does_nothing() {
let clicked = click_text_field_then_type("hello", false, |r| egui::pos2(r.left() + 0.5, r.center().y));
assert_eq!(clicked, ("hello".to_owned(), false));
}
/// The popup arrow drawn in a [`super::text_field_with_trailing`]'s trailing space (the Type
/// tool's font field) keeps its own click now that the editor covers the rest of the box
/// (#12302): the editor stops short of that space.
#[test]
fn text_field_trailing_arrow_keeps_its_click() {
let id = egui::Id::unique("tf");
// The text, whether the arrow was clicked, the arrow's rect and whether the editor has the focus.
let mut h = Harness::new_ui_state(
move |ui, s: &mut (String, bool, egui::Rect, bool)| {
let (input, rect) = super::text_field_with_trailing(ui, id, &mut s.0, "", 140.0, super::POPUP_ARROW_W);
drop(input.accessible_name("Font"));
s.2 = egui::Rect::from_min_max(egui::pos2(rect.right() - super::POPUP_ARROW_W - 2.0, rect.top() + 2.0), rect.max - egui::vec2(2.0, 2.0));
let arrow = ui.interact(s.2, id.with("arrow"), egui::Sense::click());
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Font"));
s.1 |= arrow.clicked();
s.3 = ui.memory(|m| m.has_focus(id));
},
("hello".to_owned(), false, egui::Rect::NOTHING, false),
);
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run();
let pos = h.state().2.center();
click_at(&mut h, pos, egui::PointerButton::Primary);
assert!(h.state().1, "the arrow takes the click");
assert!(!h.state().3, "the editor doesn't take the focus");
}
/// #12302: a click inside the editor keeps egui's own focus and caret placement: the box behind
/// it doesn't take the click, and the caret lands inside the word rather than at either end.
#[test]
fn text_field_editor_click_places_the_caret_at_the_pointer() {
let (text, focused) = click_text_field_then_type("hello", true, |r| egui::pos2(r.left() + 20.0, r.center().y));
assert!(focused);
assert_eq!(text.len(), 6, "{text:?}");
assert!(text != "Xhello" && text != "helloX", "the X lands inside the word: {text:?}");
}
/// #2150: text queued by the control channel (`ui.type`) joins the input in `raw_input_hook`
/// and is cut like the OS's, so the cap must run after it is appended.
#[test]
@@ -918,7 +1040,7 @@ fn click_and_type(h: &mut egui_kittest::Harness<'static, (f32, u32)>, text: &str
}
/// A button down (and up) at `pos`.
fn click_at(h: &mut egui_kittest::Harness<'static, (f32, u32)>, pos: egui::Pos2, button: egui::PointerButton) {
fn click_at<S>(h: &mut egui_kittest::Harness<'_, S>, pos: egui::Pos2, button: egui::PointerButton) {
h.event(egui::Event::PointerMoved(pos));
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos, button, pressed, modifiers: egui::Modifiers::NONE });
+9 -5
View File
@@ -84,6 +84,10 @@ fn text_field_in(
let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) };
let mut inner = rect.shrink2(TEXT_FIELD_PAD);
inner.max.x = (inner.max.x - trailing).max(inner.min.x + 1.0);
// The editor covers the whole box but the trailing space, with the padding as its own margin,
// so a click in the padding is a click in the editor: it focuses it and places the caret, in
// a scrolled value too, as a click on the text does (#12302).
let editor = inner.expand2(TEXT_FIELD_PAD);
let limit = char_limit.unwrap_or(TEXT_FIELD_LIMIT);
// A value already past the limit (restored from a state file, set by automation) is shown and
// edited as its first `limit` characters, so it is never laid out whole; the caller's string
@@ -98,17 +102,17 @@ fn text_field_in(
let edit = egui::TextEdit::singleline(shown)
.id(id)
.hint_text(hint.to_owned())
.frame(egui::Frame::NONE)
.frame(egui::Frame::NONE.inner_margin(TEXT_FIELD_PAD))
.font(font)
.min_size(inner.size())
.min_size(editor.size())
.align(egui::Align2::LEFT_CENTER)
.char_limit(limit);
// A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected
// list rows and slider trails, which must keep the theme's.
let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(layout));
// The theme's 24 pt interact height would make the edit taller than the box's inside.
child.spacing_mut().interact_size.y = inner.height();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(editor).layout(layout));
// The theme's 24 pt interact height would make the edit taller than the box.
child.spacing_mut().interact_size.y = editor.height();
child.style_mut().visuals.selection.bg_fill = t.field_selection();
let response = child.add(edit);
if let Some(b) = bounded