Form widgets carry ids scoped by the form's opening and field path

A form's text fields had positional egui ids, so focus and typed text moved to the
field at the same place in the next form (#219).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 18:16:25 -04:00
1 parent 94a10a1bf3
commit 1fc9d1e8d5
4 files changed
+34 -14

No files matched your search

+5 -2
View File
@@ -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 {
+7 -5
View File
@@ -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<f64>, hint: &str, integer: bool, width: f32) -> NumberEdit {
let shown = |v: Option<f64>| v.map(format_number).unwrap_or_default();
let mut draft = ui.data(|d| d.get_temp::<Draft>(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<f64>, 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.
+8 -6
View File
@@ -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<String>,
/// 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::<Value>(&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);
+14 -1
View File
@@ -159,6 +159,9 @@ pub struct ParamsDialog {
pub error: Option<String>,
/// 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<Status>,
pub dialog: Option<ParamsDialog>,
/// 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, &params));
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,
});
}
}