diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 813186f..4541ec6 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -75,6 +75,9 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { app.dialog = None; return; }; + // The form's widgets are its own, keyed by this opening: the keyboard focus and typed text of + // the form it replaces (egui drops focus from a widget no longer drawn) never land here. + let form_id = egui::Id::new(("fc_form", spec.id, d.opening)); let (mut run, mut cancel) = (false, false); heading(ui, t, Level::Title, |ui| { ui.label(RichText::new(spec.label.to_uppercase()).font(label_font(ui.ctx(), 12.0))); @@ -125,7 +128,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { d.advanced = advanced; match (spec.form, object) { (Some(fields), Some(mut obj)) if !d.advanced => { - let cx = crate::form::Context { units, names: &|c| session.names(c), salt: spec.id }; + let cx = crate::form::Context { units, names: &|c| session.names(c), salt: form_id }; let out = crate::form::show(ui, t, &cx, fields, &mut obj); invalid = out.invalid; if out.changed { @@ -134,7 +137,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { } } _ => { - ui.add(egui::TextEdit::multiline(&mut d.text).code_editor().desired_rows(6).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::multiline(&mut d.text).id(form_id.with("json")).code_editor().desired_rows(6).desired_width(f32::INFINITY)); } } if let Some(e) = &d.error { diff --git a/crates/ui-egui/src/fields.rs b/crates/ui-egui/src/fields.rs index ff889a8..4c15162 100644 --- a/crates/ui-egui/src/fields.rs +++ b/crates/ui-egui/src/fields.rs @@ -67,7 +67,9 @@ struct Draft { } /// A number field: shows `value` (or the typed text), greys `hint` when empty, and reports a new -/// value only when the text is a finite number (or empty). `integer` refuses fractions. +/// value only when the text is a finite number (or empty). `integer` refuses fractions. `id` is the +/// field's own (unique in the form, and differing between forms): it keys both the typed text and +/// the widget, so focus and typing never move to another form's field at the same place. pub fn number(ui: &mut Ui, s: &Style, id: Id, value: Option, hint: &str, integer: bool, width: f32) -> NumberEdit { let shown = |v: Option| v.map(format_number).unwrap_or_default(); let mut draft = ui.data(|d| d.get_temp::(id)).filter(|d| d.over == value).unwrap_or_else(|| Draft { text: shown(value), over: value }); @@ -80,7 +82,7 @@ pub fn number(ui: &mut Ui, s: &Style, id: Id, value: Option, hint: &str, in (!integer || x.fract() == 0.0).then_some(Some(x)) }; let bad = parse(&draft.text).is_none(); - let mut edit = egui::TextEdit::singleline(&mut draft.text).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim)); + let mut edit = egui::TextEdit::singleline(&mut draft.text).id(id).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim)); if bad { edit = edit.text_color(s.error); } @@ -124,9 +126,9 @@ pub fn combo(ui: &mut Ui, id: Id, selected: &str, options: &[&str], width: f32) picked } -/// A one-line text field with a greyed hint; true when it changed. -pub fn text(ui: &mut Ui, s: &Style, value: &mut String, hint: &str, width: f32) -> bool { - ui.add(egui::TextEdit::singleline(value).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim))).changed() +/// A one-line text field with a greyed hint, keyed by `id` (see [`number`]); true when it changed. +pub fn text(ui: &mut Ui, s: &Style, id: Id, value: &mut String, hint: &str, width: f32) -> bool { + ui.add(egui::TextEdit::singleline(value).id(id).desired_width(width).hint_text(RichText::new(hint).italics().color(s.dim))).changed() } /// A unit after a field. diff --git a/crates/ui-egui/src/form.rs b/crates/ui-egui/src/form.rs index 11d2afe..566f4d3 100644 --- a/crates/ui-egui/src/form.rs +++ b/crates/ui-egui/src/form.rs @@ -24,8 +24,9 @@ pub struct Context<'a> { pub units: UnitSystem, /// The names of a collection's items. pub names: &'a dyn Fn(Collection) -> Vec, - /// A salt for the widget ids (the command). - pub salt: &'a str, + /// A salt for the widget ids: the open form (its command and the opening), so no widget + /// state is shared with another form. + pub salt: Id, } /// What a frame of the form did. @@ -147,7 +148,7 @@ impl Form<'_> { Kind::Integer { .. } => self.number(ui, id, value, f.hint, true, FIELD_W), Kind::Text | Kind::Path { .. } => { let mut text = value.and_then(Value::as_str).unwrap_or_default().to_string(); - fields::text(ui, &s, &mut text, f.hint, FIELD_W + 40.0) + fields::text(ui, &s, id, &mut text, f.hint, FIELD_W + 40.0) .then(|| if text.is_empty() && !f.required { Value::Null } else { json!(text) }) } Kind::Bool => { @@ -273,7 +274,8 @@ impl Form<'_> { if names.is_empty() { let mut text = current.unwrap_or_default(); let hint = format!("no {} yet", c.label()); - return fields::text(ui, &self.style, &mut text, &hint, FIELD_W + 40.0).then(|| if text.is_empty() { Value::Null } else { json!(text) }); + return fields::text(ui, &self.style, id, &mut text, &hint, FIELD_W + 40.0) + .then(|| if text.is_empty() { Value::Null } else { json!(text) }); } let empty = if hint.is_empty() { "—" } else { hint }; let selected = current.clone().unwrap_or_else(|| empty.to_string()); @@ -311,7 +313,7 @@ impl Form<'_> { let shown = value.map(Value::to_string).unwrap_or_default(); let mut text = ui.data(|d| d.get_temp::<(String, String)>(id)).filter(|(_, over)| *over == shown).map_or_else(|| shown.clone(), |(t, _)| t); let bad = !text.trim().is_empty() && serde_json::from_str::(&text).is_err(); - let mut edit = egui::TextEdit::singleline(&mut text).code_editor().desired_width(FIELD_W + 40.0); + let mut edit = egui::TextEdit::singleline(&mut text).id(id).code_editor().desired_width(FIELD_W + 40.0); if bad { edit = edit.text_color(self.style.error); self.out.invalid += 1; @@ -436,7 +438,7 @@ mod tests { let width = crate::context::WIDTH - 16.0; let mut used = 0.0; let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let cx = Context { units: UnitSystem::MmTonneS, names: &names, salt: c.id }; + let cx = Context { units: UnitSystem::MmTonneS, names: &names, salt: Id::new(c.id) }; ui.vertical(|ui| { ui.set_max_width(width); outcome = show(ui, &Tokens::get(false), &cx, form, &mut params); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c09441c..5d53956 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -159,6 +159,9 @@ pub struct ParamsDialog { pub error: Option, /// The JSON text is shown instead of the typed fields. pub advanced: bool, + /// Which opening of a form this is (counts up): a form opened over another one, even of the + /// same command, gets fresh widget ids, so nothing typed into the one it replaces carries over. + pub opening: u64, } /// A status bar message. @@ -263,6 +266,8 @@ pub struct ModelApp { pub cam: Camera, pub status: Option, pub dialog: Option, + /// Forms opened so far (numbers each [`ParamsDialog::opening`]). + forms_opened: u64, pub viewport: Viewport, pub icons: icons::IconCache, /// The toolbar's buttons drawn last frame, by catalogue id (for the control channel). @@ -355,6 +360,7 @@ impl ModelApp { cam: Camera::default(), status: None, dialog: None, + forms_opened: 0, viewport: Viewport::default(), icons: icons::IconCache::default(), toolbar_rects: Vec::new(), @@ -528,7 +534,14 @@ impl ModelApp { if let Some(spec) = fieldcraft_engine::find_command(command) { // The schema's defaults are filled in, so the form starts as the command would run. let params = spec.form.map_or(params.clone(), |form| fieldcraft_engine::schema::prefill(form, ¶ms)); - self.dialog = Some(ParamsDialog { command: spec.id, text: params.to_string(), error: None, advanced: spec.form.is_none() }); + self.forms_opened = self.forms_opened.wrapping_add(1); + self.dialog = Some(ParamsDialog { + command: spec.id, + text: params.to_string(), + error: None, + advanced: spec.form.is_none(), + opening: self.forms_opened, + }); } }