Skip to main content

max / audiofiles

Describe the name modal, and seal the last four bare text inputs The name modal is one helper behind four screens (New Vault, Rename Vault, New Folder, Rename Folder), and every sweep of this port matched it and walked past it: `widgets.rs` is not a screen file, so the candidate lists kept naming panels. It was a described field hand-drawn. Label above the control, muted standing help above that, a danger-coloured error below the input, and no well - the same shape the vault-setup name and the rule name were in before they were described. The two message slots split. `hint` used to be one parameter doing two jobs: "Folder names cannot contain /" is a constraint on the answer and is now the field's hint, while "A vault is a separate sample collection..." is a fact about the modal and stays a lead-in above the field. Same line the export screen draws between a field's message and its footer. That is what makes a `NameModalSpec` worth having, next to the `ConfirmSpec` already here: seven positional parameters, two of them `Option<&str>` and no longer interchangeable. Not `required`, for the reason the license key is not: an empty submit closes the modal as a no-op, so the marker would claim a refusal that never happens. THE SWEEP, done a third time and by a different method. The pattern lists were exhaustive over the call shapes each one thought to match, which is how `ui.radio(` cost a whole screen last pass. This one enumerates every `ui.<method>(` in both crates and classifies the result, so it cannot miss a widget by not having guessed its name. What it found beyond the modal: four bare `ui.text_edit_singleline` calls (settings' inline vault rename, the sidebar's collection rename and create, the per-folder tag input) against `text_field`'s own doc saying every `TextEdit` in the app goes through it. They are inline affordances and stay controls, so they take `text_field` rather than a description - the per-folder tag input sat directly under an apply-to-all input that already had the well. Everything else `ui.add` carries is a Slider, a DragValue, a ProgressBar, a Label or a Button. No unconverted described-field candidate remains that is not blocked on one of the four filed gaps. 1347 tests pass, clippy clean, fmt clean. No crate publish needed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 03:33 UTC
Signed with PGP, not checked
Commit: 21dd4e6d5a011244a8e9aef4115c5bbce7c136e1
Parent: 5d08840
5 files changed, +121 insertions, -50 deletions
@@ -976,18 +976,24 @@
976 976
977 977 /// Draw the "New Vault" modal: text input for vault name.
978 978 pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
979 - let hint = "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.";
979 + // What a vault is, and how to nest one, are facts about the modal rather
980 + // than about the name being typed, so they stay a lead-in above the field.
981 + let lead_in = "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.";
980 982 // C-3: clone the error into a local so the &mut input borrow doesn't
981 983 // conflict with the immutable error borrow into name_modal.
982 984 let error_owned = state.vfs_modal.name_modal_error.clone();
983 985 let outcome = widgets::name_modal(
984 986 ctx,
985 - "New Vault",
986 - Some(hint),
987 - "Vault name:",
987 + &widgets::NameModalSpec {
988 + title: "New Vault",
989 + lead_in: Some(lead_in),
990 + label: "Vault name",
991 + hint: None,
992 + placeholder: None,
993 + submit_label: "Create",
994 + error: error_owned.as_deref(),
995 + },
988 996 &mut state.vfs_modal.vfs_create_input,
989 - "Create",
990 - error_owned.as_deref(),
991 997 );
992 998 handle_name_modal_outcome(
993 999 outcome,
@@ -1009,12 +1015,17 @@
1009 1015 (
1010 1016 widgets::name_modal(
1011 1017 ctx,
1012 - "Rename Vault",
1013 - Some("Vault names can contain spaces."),
1014 - "New name:",
1018 + &widgets::NameModalSpec {
1019 + title: "Rename Vault",
1020 + lead_in: None,
1021 + label: "New name",
1022 + // About the answer, so it is the field's hint.
1023 + hint: Some("Vault names can contain spaces."),
1024 + placeholder: None,
1025 + submit_label: "Save",
1026 + error: error_owned.as_deref(),
1027 + },
1015 1028 name_buf,
1016 - "Save",
1017 - error_owned.as_deref(),
1018 1029 ),
1019 1030 id,
1020 1031 )
@@ -1040,12 +1051,17 @@
1040 1051 let error_owned = state.vfs_modal.name_modal_error.clone();
1041 1052 let outcome = widgets::name_modal(
1042 1053 ctx,
1043 - "New Folder",
1044 - Some("Folder names cannot contain /"),
1045 - "Folder name:",
1054 + &widgets::NameModalSpec {
1055 + title: "New Folder",
1056 + lead_in: None,
1057 + label: "Folder name",
1058 + // A constraint on the answer, so it is the field's hint.
1059 + hint: Some("Folder names cannot contain /"),
1060 + placeholder: None,
1061 + submit_label: "Create",
1062 + error: error_owned.as_deref(),
1063 + },
1046 1064 &mut state.vfs_modal.dir_create_input,
1047 - "Create",
1048 - error_owned.as_deref(),
1049 1065 );
1050 1066 let vfs_id = state.current_vfs_id();
1051 1067 let current_dir = state.nav.current_dir;
@@ -1074,12 +1090,16 @@
1074 1090 (
1075 1091 widgets::name_modal(
1076 1092 ctx,
1077 - "Rename",
1078 - None,
1079 - "New name:",
1093 + &widgets::NameModalSpec {
1094 + title: "Rename",
1095 + lead_in: None,
1096 + label: "New name",
1097 + hint: None,
1098 + placeholder: None,
1099 + submit_label: "Save",
1100 + error: error_owned.as_deref(),
1101 + },
1080 1102 name_buf,
1081 - "Save",
1082 - error_owned.as_deref(),
1083 1103 ),
1084 1104 id,
1085 1105 )
@@ -220,7 +220,8 @@
220 220 // which panicked-per-frame if the option was cleared mid-frame).
221 221 let mut submit = false;
222 222 if let Some((_, name)) = state.settings.rename_target.as_mut() {
223 - let resp = ui.text_edit_singleline(name);
223 + let resp =
224 + widgets::text_field(ui, egui::TextEdit::singleline(name));
224 225 submit = resp.lost_focus()
225 226 && ui.input(|i| i.key_pressed(egui::Key::Enter));
226 227 }
@@ -449,7 +449,7 @@
449 449 let Some((_, buf)) = state.collections_ui.collection_rename_target.as_mut() else {
450 450 return;
451 451 };
452 - let resp = ui.text_edit_singleline(buf);
452 + let resp = widgets::text_field(ui, egui::TextEdit::singleline(buf));
453 453 if want_focus {
454 454 resp.request_focus();
455 455 }
@@ -479,8 +479,10 @@
479 479 if state.collections_ui.show_collection_create {
480 480 let want_focus = std::mem::take(&mut state.focus_inline_editor);
481 481 ui.horizontal(|ui| {
482 - let resp =
483 - ui.text_edit_singleline(&mut state.collections_ui.collection_create_input);
482 + let resp = widgets::text_field(
483 + ui,
484 + egui::TextEdit::singleline(&mut state.collections_ui.collection_create_input),
485 + );
484 486 if want_focus {
485 487 resp.request_focus();
486 488 }
@@ -246,51 +246,96 @@
246 246 outcome
247 247 }
248 248
249 - /// Single-field name modal: title, optional hint, label, text input,
249 + /// Everything a [`name_modal`] draws except the answer itself.
250 + ///
251 + /// A struct rather than more positional parameters, for the reason
252 + /// [`ConfirmSpec`] is one: the modal already took seven, and the two message
253 + /// slots it now separates (`lead_in` and `hint`) are both `Option<&str>` and
254 + /// would be indistinguishable at a call site.
255 + ///
256 + /// `lead_in` and `hint` are that separation. A lead-in is about the modal as a
257 + /// whole, so it stays above the field ("A vault is a separate sample
258 + /// collection..."); a hint is standing help about the answer, so it belongs to
259 + /// the field and renders under the input ("Folder names cannot contain /").
260 + /// Same line the export screen draws between a field's message and its footer.
261 + pub struct NameModalSpec<'a> {
262 + /// Window title.
263 + pub title: &'a str,
264 + /// Standing help about the modal, drawn above the field.
265 + pub lead_in: Option<&'a str>,
266 + /// The field's label. No trailing colon: `field()` draws the label.
267 + pub label: &'a str,
268 + /// Standing help about the answer, drawn under the input.
269 + pub hint: Option<&'a str>,
270 + /// Ghost text shown while the input is empty.
271 + pub placeholder: Option<&'a str>,
272 + /// Label on the submit button.
273 + pub submit_label: &'a str,
274 + /// What is wrong with the answer now, drawn under the input.
275 + pub error: Option<&'a str>,
276 + }
277 +
278 + /// Single-field name modal: title, optional lead-in, one described text field,
250 279 /// submit/cancel. Enter in the field submits.
251 280 ///
281 + /// The field is described rather than hand-drawn, which is what moves the label,
282 + /// the hint and the error onto the question they are about and paints the input
283 + /// the same well every other text input in the app has. It is deliberately not
284 + /// `required`: an empty submit closes the modal as a no-op (see
285 + /// `handle_name_modal_outcome`), so the marker would claim a refusal that never
286 + /// happens. Same call the license-key field made.
287 + ///
252 288 /// Autofocus: the text field grabs focus on first open (detected as "input is
253 289 /// empty and nothing in the app currently has focus"). After the user clicks
254 290 /// any widget the autofocus stops firing, so Cancel/Submit clicks aren't
255 291 /// stolen back by the input.
256 292 pub fn name_modal(
257 293 ctx: &egui::Context,
258 - title: &str,
259 - hint: Option<&str>,
260 - label: &str,
294 + spec: &NameModalSpec<'_>,
261 295 input: &mut String,
262 - submit_label: &str,
263 - error: Option<&str>,
264 296 ) -> NameModalOutcome {
265 297 let mut outcome = NameModalOutcome::None;
266 - modal_window(ctx, title, false, None, |ui| {
267 - if let Some(h) = hint {
268 - ui.label(egui::RichText::new(h).small().color(theme::content_muted()));
298 + modal_window(ctx, spec.title, false, None, |ui| {
299 + if let Some(lead_in) = spec.lead_in {
300 + ui.label(
301 + egui::RichText::new(lead_in)
302 + .small()
303 + .color(theme::content_muted()),
304 + );
269 305 ui.add_space(theme::space::bound());
270 306 }
271 - ui.label(label);
272 - let resp = ui.text_edit_singleline(input);
273 - if input.is_empty() && ui.memory(|m| m.focused().is_none()) {
274 - resp.request_focus();
275 - }
276 - // C-3: inline error below the input. Re-focus the input when an error
277 - // is surfaced so the user can edit and retry without re-clicking.
278 - if let Some(err) = error {
279 - ui.add_space(theme::space::hair());
280 - ui.label(egui::RichText::new(err).small().color(theme::danger()));
281 - if !resp.has_focus() {
307 + let name_field = makeover_layout::Field {
308 + hint: spec.hint,
309 + placeholder: spec.placeholder,
310 + error: spec.error,
311 + ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "name", spec.label)
312 + };
313 + let resp = field(
314 + ui,
315 + &name_field,
316 + makeover_immediate::Filling::Text(&mut *input),
317 + None,
318 + );
319 + if let Some(resp) = resp {
320 + if input.is_empty() && ui.memory(|m| m.focused().is_none()) {
282 321 resp.request_focus();
283 322 }
284 - }
285 - if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
286 - outcome = NameModalOutcome::Submitted(input.trim().to_string());
323 + // C-3: re-focus the input when an error is surfaced so the user can
324 + // edit and retry without re-clicking. The message itself is the
325 + // field's now, so nothing here draws it.
326 + if spec.error.is_some() && !resp.has_focus() {
327 + resp.request_focus();
328 + }
329 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
330 + outcome = NameModalOutcome::Submitted(input.trim().to_string());
331 + }
287 332 }
288 333 ui.add_space(theme::space::peer());
289 334 ui.horizontal(|ui| {
290 335 if secondary_button(ui, "Cancel").clicked() {
291 336 outcome = NameModalOutcome::Cancelled;
292 337 }
293 - if secondary_button(ui, submit_label).clicked() {
338 + if secondary_button(ui, spec.submit_label).clicked() {
294 339 outcome = NameModalOutcome::Submitted(input.trim().to_string());
295 340 }
296 341 });
@@ -104,7 +104,10 @@
104 104
105 105 ui.horizontal(|ui| {
106 106 ui.label("Tags:");
107 - ui.text_edit_singleline(&mut entry.tag_input);
107 + widgets::text_field(
108 + ui,
109 + egui::TextEdit::singleline(&mut entry.tag_input),
110 + );
108 111 });
109 112
110 113 if !entry.tag_input.trim().is_empty() {