Merge pull request #922 from pravdomil/claude/dialog-focus-on-open

UI: dialogs open with their first text field focused and selected
This commit is contained in:
Brandon Thomas 2026-10-12 04:20:55 -04:00
commit cc45d65204
8 changed files with 99 additions and 4 deletions

View File

@ -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);

View File

@ -400,6 +400,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);
@ -675,6 +676,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();

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| {

View File

@ -765,6 +765,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| {
@ -773,9 +774,21 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let r = match &mut fl.kind {
FieldKind::Number { value, speed } => {
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
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec)));
// The first field takes the keyboard when the form opens. (A number edits
// as text once focused; the selection is clamped to what it shows.)
if !std::mem::replace(&mut focused, true) {
crate::widgets::focus_on_open(ui.ctx(), &r, &" ".repeat(64));
}
r.rect
}
FieldKind::Text(s) => {
let r = ui.add(egui::TextEdit::singleline(s).desired_width(220.0));
if !std::mem::replace(&mut focused, true) {
crate::widgets::focus_on_open(ui.ctx(), &r, s);
}
r.rect
}
FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect,
FieldKind::Path { value, .. } => {
ui.horizontal(|ui| {
let r = ui.add(egui::TextEdit::singleline(value).desired_width(150.0)).rect;
@ -947,6 +960,37 @@ mod tests {
assert_eq!(f.params(), json!({"prop": "position", "value": [10.0, 20.0], "mode": "b"}));
}
/// A numeric form (Layer ▸ Transform ▸ Position…) opens with its first number being edited
/// and selected: typing replaces it, and one Enter confirms.
#[test]
fn a_numeric_form_opens_with_its_first_number_selected() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
app.dialog_state.form = Form {
title: "Position".into(),
command: "layer.setTransform".into(),
base: json!({"prop": "position"}),
fields: vec![Field::num("value[0]", "X", 960.0), Field::num("value[1]", "Y", 540.0)],
};
app.dialog = Some(Dialog::Form);
let frame = |app: &mut EffectcraftApp, events: Vec<egui::Event>| {
let raw = egui::RawInput { events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.auto.begin_frame();
crate::panels::dialogs::show(app, ui.ctx());
})
.textures_delta
.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("42".into())]);
assert_eq!(app.dialog_state.form.params()["value"], json!([42.0, 540.0]));
frame(&mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
assert!(app.dialog.is_none(), "one Enter confirms");
}
/// File ▸ Export ▸ Adobe Premiere Pro Project… opens a form that says it writes Final Cut Pro
/// XML; its fields register automation ids and OK runs `file.exportTimeline`.
#[test]

View File

@ -139,6 +139,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:"));

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);

View File

@ -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);

View File

@ -194,6 +194,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();