Inline editors: the whole name is selected only as a rename opens; arrows then move the caret (#488)

widgets::keep_focus (Project panel rename and comment, Render Queue, Essential
Graphics and Timeline fields) selected the whole text whenever it saw its field
without the keyboard focus, not just on the frame the editor opened. Anything
that took the focus for a frame (an input method, another handler) then
re-selected the name, so the arrow keys seemed to do nothing and Backspace erased
the whole name, as reported on Linux. The editor now remembers the pass it was
last shown: it selects all only when it opens and otherwise just takes the
keyboard back with the caret where it was.

The harness does not reproduce the report's Linux behaviour itself (arrows reach
the field there); the tests cover the path that did: a unit test drops the focus
for a frame and checks the caret survives (and that reopening selects all), and a
kittest renames a Project item and a Timeline layer with Left / Right / Up /
Down / End and Backspace, editing part of each name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 22:54:56 +00:00
1 parent 6a3d5c6703
commit d6894249fa
2 files changed
+98 -4

No files matched your search

+57 -4
View File
@@ -165,12 +165,24 @@ pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget {
}
/// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it
/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value.
/// (Only then is the focus requested: a request resets what keys the field keeps for itself,
/// such as Tab with [`egui::TextEdit::lock_focus`].)
/// opens its whole `text` is selected, so typing replaces the value; after that the caret is the
/// user's (arrow keys move it), even if the field is without the focus for a frame (#488).
/// (The focus is only requested while the field doesn't have it: a request resets what keys the
/// field keeps for itself, such as Tab with [`egui::TextEdit::lock_focus`].)
pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) {
// The editor is open in consecutive passes; a gap means it was closed and opened again.
let shown = field.id.with("keep-focus-pass");
let pass = ctx.cumulative_pass_nr();
let previous = ctx.data_mut(|d| {
let previous = d.get_temp::<u64>(shown);
d.insert_temp(shown, pass);
previous
});
let opening = previous.is_none_or(|p| p.saturating_add(1) < pass);
if !ctx.memory(|m| m.has_focus(field.id)) {
select_all(ctx, field.id, text);
if opening {
select_all(ctx, field.id, text);
}
field.request_focus();
}
}
@@ -592,6 +604,47 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
mod tests {
use super::*;
/// #488: an inline editor selects its whole text only as it opens. Should it be without the
/// keyboard for a frame later (an input method, another handler), it takes the keyboard back
/// with the caret where the user put it instead of selecting everything again, which made
/// the arrows seem dead and Backspace erase the whole name.
#[test]
fn inline_editors_select_all_only_as_they_open() {
let ctx = egui::Context::default();
let mut text = String::from("Comp 1");
let mut id = egui::Id::NULL;
let frame = |ctx: &egui::Context, text: &mut String, id: &mut egui::Id| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let r = ui.add(egui::TextEdit::singleline(text));
keep_focus(ui.ctx(), &r, text);
*id = r.id;
});
out.textures_delta.clear();
};
let range = |ctx: &egui::Context, id: egui::Id| egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range());
let ix = |n: usize| egui::text::CCursor::new(n).index;
frame(&ctx, &mut text, &mut id);
frame(&ctx, &mut text, &mut id);
let all = range(&ctx, id).unwrap();
assert_eq!((all.primary.index.min(all.secondary.index), all.primary.index.max(all.secondary.index)), (ix(0), ix(6)), "opened with all selected");
// The caret after "Co", then a frame without the keyboard.
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(2))));
state.store(&ctx, id);
ctx.memory_mut(|m| m.surrender_focus(id));
frame(&ctx, &mut text, &mut id);
frame(&ctx, &mut text, &mut id);
assert!(ctx.memory(|m| m.has_focus(id)), "the editor keeps the keyboard");
let r = range(&ctx, id).unwrap();
assert_eq!((r.primary.index, r.secondary.index), (ix(2), ix(2)), "the caret stays where it was");
// Closed for a frame and opened again: all selected again.
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
frame(&ctx, &mut text, &mut id);
frame(&ctx, &mut text, &mut id);
let r = range(&ctx, id).unwrap();
assert_eq!((r.primary.index.min(r.secondary.index), r.primary.index.max(r.secondary.index)), (ix(0), ix(6)));
}
/// #581: a field's text edit fills it, so its text and hint sit on the field's middle line
/// (it was a row at the top of the field, its text high).
#[test]
+41
View File
@@ -474,3 +474,44 @@ fn a_layout_switch_keeps_the_name_field_and_arabic_names_are_kept() {
assert_eq!(focused.trim(), "File", "Alt focused the menu bar");
}
}
/// #488: once a rename field is open, the arrow keys move its caret (the whole name is selected
/// only as it opens), so part of a name can be edited.
#[test]
fn arrow_keys_move_the_caret_in_rename_fields() {
let (mut h, comp) = harness();
let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone();
let p = center(&h, &format!("project.item.{comp}.name"));
click(&mut h, p, 1);
key(&mut h, Key::Enter);
// The arrows keep the field (Up / Down too); in "Main", two to the left of its end.
key(&mut h, Key::ArrowRight);
key(&mut h, Key::ArrowLeft);
key(&mut h, Key::ArrowUp);
key(&mut h, Key::ArrowDown);
key(&mut h, Key::End);
key(&mut h, Key::ArrowLeft);
key(&mut h, Key::ArrowLeft);
type_text(&mut h, "X");
key(&mut h, Key::Backspace);
key(&mut h, Key::Backspace);
type_text(&mut h, "Y");
key(&mut h, Key::Enter);
assert_eq!(name(&h), "MYin");
// The Timeline's layer rename.
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap();
h.run_steps(3);
let p = center(&h, &format!("timeline.layer.{layer}.row"));
click(&mut h, p, 1);
key(&mut h, Key::Enter);
key(&mut h, Key::ArrowRight);
key(&mut h, Key::ArrowUp);
key(&mut h, Key::End);
key(&mut h, Key::ArrowLeft);
key(&mut h, Key::ArrowLeft);
type_text(&mut h, "-");
key(&mut h, Key::Enter);
let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone();
assert_eq!(layer_name, "Pla-te");
}