UI: dialogs open with their first text field focused and selected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-11 23:13:53 +02:00
co-authored by Claude Opus 5.5
parent 67434e9038
commit 8e1d742a9d
8 changed files with 62 additions and 3 deletions
@@ -177,6 +177,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr
ui.horizontal(|ui| {
ui.label(tr("Composition Name:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
auto.add("dialog.comp.name", r.rect, "Composition Name");
});
ui.add_space(8.0);
+27
View File
@@ -353,6 +353,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
ui.label(tr("Name:"));
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &name);
auto.add("dialog.solid.name", r.rect, "Name");
});
ui.add_space(8.0);
@@ -628,6 +629,32 @@ mod tests {
assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default");
}
#[test]
fn a_dialog_opens_with_its_name_field_focused_and_selected() {
let (mut app, ctx) = app();
let selected = |ctx: &egui::Context| {
let id = ctx.memory(|m| m.focused())?;
egui::TextEdit::load_state(ctx, id)?.cursor.char_range().map(|r| r.as_sorted_char_range()).map(|r| r.start.0..r.end.0)
};
open_new_comp(&mut app);
let name = app.dialog_state.comp.name.clone();
frame(&mut app, &ctx);
frame(&mut app, &ctx);
assert_eq!(selected(&ctx), Some(0..name.chars().count()), "typing replaces “{name}”");
// Afterwards the focus is the user's: it is not taken back.
ctx.memory_mut(|m| m.stop_text_input());
frame(&mut app, &ctx);
assert!(ctx.memory(|m| m.focused()).is_none());
// Closed and opened again: focused again.
app.dialog = None;
frame(&mut app, &ctx);
frame(&mut app, &ctx);
open_new_solid(&mut app).unwrap();
frame(&mut app, &ctx);
frame(&mut app, &ctx);
assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count()));
}
#[test]
fn composition_settings_register_every_control_and_new_comp_remembers() {
let (mut app, ctx) = app();
+4 -2
View File
@@ -173,7 +173,8 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.end_row();
ui.label(tr("Name"));
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(240.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
ui.end_row();
ui.label(tr("Preset"));
let focal = focal_for_zoom(w, d.zoom);
@@ -258,7 +259,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| {
egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label(tr("Name"));
ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
ui.end_row();
ui.label(tr("Light Type"));
egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| {
+9 -1
View File
@@ -766,6 +766,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let h = 120.0 + rows;
let mut regs: Vec<(String, egui::Rect, String)> = vec![];
let mut browse: Option<usize> = None;
let mut focused = false;
// Wide enough for a label column and a 240 px popup without a horizontal scroll bar.
super::dialogs::modal(ctx, &f.title.clone(), vec2(480.0, h), t, |ui| {
egui::Grid::new("form-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
@@ -776,7 +777,14 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let dec = if *speed < 0.01 { 5 } else { 3 };
ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect
}
FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect,
FieldKind::Text(s) => {
let r = ui.add(egui::TextEdit::singleline(s).desired_width(220.0));
// The first text field takes the keyboard when the form opens.
if !std::mem::replace(&mut focused, true) {
crate::widgets::focus_on_open(ui.ctx(), &r, s);
}
r.rect
}
FieldKind::Path { value, .. } => {
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect;
+1
View File
@@ -137,6 +137,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.end_row();
ui.label(tr("Comment:"));
let r = ui.add(egui::TextEdit::multiline(&mut d.comment).desired_rows(3).desired_width(320.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.comment);
app.auto.add("dialog.marker.comment", r.rect, "Comment");
ui.end_row();
ui.label(tr("Chapter:"));
+1
View File
@@ -115,6 +115,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
ui.label(tr("New composition name:"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
app.auto.add("dialog.precompose.name", r.rect, "New composition name");
});
ui.add_space(10.0);
@@ -420,6 +420,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
// ---- search + command list + editor
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tr("Search commands or shortcuts")).desired_width(360.0));
crate::widgets::focus_on_open(ui.ctx(), &r, &st.query);
app.auto.add("shortcuts.search", r.rect, "Search");
});
ui.add_space(4.0);
+18
View File
@@ -187,6 +187,24 @@ pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) {
}
}
/// Put the keyboard on a dialog's first text field the frame the dialog opens, with its whole
/// `text` selected, so typing replaces the value without a click. Unlike [`keep_focus`] the
/// focus is the user's afterwards.
pub fn focus_on_open(ctx: &egui::Context, field: &Response, text: &str) {
// The dialog is open in consecutive passes; a gap means it was closed and opened again.
let shown = field.id.with("focus-on-open-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
});
if previous.is_none_or(|p| p.saturating_add(1) < pass) {
select_all(ctx, field.id, text);
field.request_focus();
}
}
/// Select all of the text field `id`'s `text`, so typing replaces it.
pub fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) {
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();