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:
commit
cc45d65204
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue