max / audiofiles
5 files changed,
+147 insertions,
-40 deletions
| @@ -379,7 +379,9 @@ | |||
| 379 | 379 | audio_retry_at: None, | |
| 380 | 380 | vault_registry, | |
| 381 | 381 | vault_setup_path: None, | |
| 382 | - | vault_setup_name: "Library".to_string(), | |
| 382 | + | // Empty, so the setup screen's ghost text is what says the name | |
| 383 | + | // falls back to "Library". See `draw_vault_setup_screen`. | |
| 384 | + | vault_setup_name: String::new(), | |
| 383 | 385 | machine_id, | |
| 384 | 386 | license_key_input: String::new(), | |
| 385 | 387 | activation_result: Arc::new(Mutex::new(None)), |
| @@ -16,11 +16,6 @@ | |||
| 16 | 16 | let default_path = vault::default_vault_path(); | |
| 17 | 17 | let existing_db = default_path.join("audiofiles.db").exists(); | |
| 18 | 18 | ||
| 19 | - | // Pre-fill the vault-name field on first entry so the fallback is visible. | |
| 20 | - | if self.vault_setup_name.is_empty() { | |
| 21 | - | self.vault_setup_name = DEFAULT_VAULT_NAME.to_string(); | |
| 22 | - | } | |
| 23 | - | ||
| 24 | 19 | egui::CentralPanel::default().show(ui, |ui| { | |
| 25 | 20 | let available = ui.available_size(); | |
| 26 | 21 | ui.add_space((available.y * 0.20).max(40.0)); | |
| @@ -45,11 +40,34 @@ | |||
| 45 | 40 | ui.add_space(theme::space::bound()); | |
| 46 | 41 | } | |
| 47 | 42 | ||
| 48 | - | // Vault name | |
| 49 | - | ui.horizontal(|ui| { | |
| 50 | - | ui.label("Name:"); | |
| 51 | - | ui.text_edit_singleline(&mut self.vault_setup_name); | |
| 52 | - | }); | |
| 43 | + | // The library's name, as a described field. The bare | |
| 44 | + | // `text_edit_singleline` it replaces was the last input | |
| 45 | + | // in either crate painting no well, the same state | |
| 46 | + | // `classifier.rs`'s rule name was in before it was | |
| 47 | + | // described. | |
| 48 | + | // | |
| 49 | + | // The name used to be pre-filled with "Library" so the | |
| 50 | + | // fallback was visible, which is what a placeholder | |
| 51 | + | // says without also committing the text: `finalize_vault_setup` | |
| 52 | + | // already falls back to the same name when the field is | |
| 53 | + | // left blank, so the ghost text is the true answer to | |
| 54 | + | // "what do I get if I type nothing" rather than a | |
| 55 | + | // default the user has to select and delete first. | |
| 56 | + | let name_field = makeover_layout::Field { | |
| 57 | + | hint: Some("Shown wherever you switch between libraries."), | |
| 58 | + | placeholder: Some(DEFAULT_VAULT_NAME), | |
| 59 | + | ..makeover_layout::Field::new( | |
| 60 | + | makeover_layout::FieldKind::Text, | |
| 61 | + | "vault_name", | |
| 62 | + | "Name", | |
| 63 | + | ) | |
| 64 | + | }; | |
| 65 | + | widgets::field( | |
| 66 | + | ui, | |
| 67 | + | &name_field, | |
| 68 | + | makeover_immediate::Filling::Text(&mut self.vault_setup_name), | |
| 69 | + | None, | |
| 70 | + | ); | |
| 53 | 71 | ||
| 54 | 72 | ui.add_space(theme::space::peer()); | |
| 55 | 73 |
| @@ -28,17 +28,13 @@ | |||
| 28 | 28 | self.edit.fade_curve = audiofiles_core::edit::FadeCurve::Linear; | |
| 29 | 29 | ||
| 30 | 30 | // Cache result mode from user_config | |
| 31 | - | self.edit.result_mode = match self | |
| 31 | + | self.edit.result_mode = self | |
| 32 | 32 | .backend | |
| 33 | 33 | .get_config(crate::backend::ConfigKey::EditResultMode) | |
| 34 | 34 | .ok() | |
| 35 | 35 | .flatten() | |
| 36 | 36 | .as_deref() | |
| 37 | - | { | |
| 38 | - | Some("replace") => Some(super::EditResultMode::Replace), | |
| 39 | - | Some("sibling") => Some(super::EditResultMode::Sibling), | |
| 40 | - | _ => None, | |
| 41 | - | }; | |
| 37 | + | .and_then(super::EditResultMode::from_value); | |
| 42 | 38 | } | |
| 43 | 39 | ||
| 44 | 40 | /// Close the floating sample editor. | |
| @@ -286,14 +282,10 @@ | |||
| 286 | 282 | ||
| 287 | 283 | /// Save the user's preferred edit result mode. | |
| 288 | 284 | pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) { | |
| 289 | - | let mode_str = match mode { | |
| 290 | - | super::EditResultMode::Replace => "replace", | |
| 291 | - | super::EditResultMode::Sibling => "sibling", | |
| 292 | - | }; | |
| 293 | 285 | super::log_backend_err( | |
| 294 | 286 | "set_config edit_result_mode", | |
| 295 | 287 | self.backend | |
| 296 | - | .set_config(crate::backend::ConfigKey::EditResultMode, mode_str), | |
| 288 | + | .set_config(crate::backend::ConfigKey::EditResultMode, mode.as_value()), | |
| 297 | 289 | ); | |
| 298 | 290 | self.edit.result_mode = Some(mode); | |
| 299 | 291 | } |
| @@ -19,6 +19,35 @@ | |||
| 19 | 19 | Sibling, | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | impl EditResultMode { | |
| 23 | + | /// The name this mode is stored and described under. | |
| 24 | + | /// | |
| 25 | + | /// One name, three uses: the value written to `ConfigKey::EditResultMode`, | |
| 26 | + | /// the value read back from it, and the `Choice::value` the edit panel's | |
| 27 | + | /// described field offers. The first two were separate string literals in | |
| 28 | + | /// `edit_workflow`, and describing the field would have made a third copy | |
| 29 | + | /// — one whose disagreement with the other two is not a compile error but a | |
| 30 | + | /// mode that round-trips to nothing on the next open. | |
| 31 | + | #[must_use] | |
| 32 | + | pub const fn as_value(self) -> &'static str { | |
| 33 | + | match self { | |
| 34 | + | Self::Replace => "replace", | |
| 35 | + | Self::Sibling => "sibling", | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// [`as_value`](Self::as_value) the other way. `None` for anything else, | |
| 40 | + | /// which is the state "the user has not chosen yet" is already stored as. | |
| 41 | + | #[must_use] | |
| 42 | + | pub fn from_value(value: &str) -> Option<Self> { | |
| 43 | + | match value { | |
| 44 | + | "replace" => Some(Self::Replace), | |
| 45 | + | "sibling" => Some(Self::Sibling), | |
| 46 | + | _ => None, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 22 | 51 | /// Data from a completed edit, pending user choice (replace vs sibling). | |
| 23 | 52 | pub struct PendingEditResult { | |
| 24 | 53 | pub source_hash: String, |
| @@ -599,27 +599,53 @@ | |||
| 599 | 599 | }); | |
| 600 | 600 | } | |
| 601 | 601 | ||
| 602 | + | /// The two answers to "Result", as the described field offers them. | |
| 603 | + | /// | |
| 604 | + | /// The values are [`EditResultMode::as_value`], which is also what the mode is | |
| 605 | + | /// stored under, so the described answer and the persisted one cannot drift | |
| 606 | + | /// apart. | |
| 607 | + | const RESULT_MODES: [makeover_layout::Choice<'static>; 2] = [ | |
| 608 | + | makeover_layout::Choice { | |
| 609 | + | value: EditResultMode::Replace.as_value(), | |
| 610 | + | label: "Replace original", | |
| 611 | + | }, | |
| 612 | + | makeover_layout::Choice { | |
| 613 | + | value: EditResultMode::Sibling.as_value(), | |
| 614 | + | label: "Create sibling", | |
| 615 | + | }, | |
| 616 | + | ]; | |
| 617 | + | ||
| 602 | 618 | /// Result mode section: replace original vs create sibling. | |
| 603 | 619 | fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 604 | - | ui.label(egui::RichText::new("Result").strong()); | |
| 620 | + | // A `Radio` rather than a `Select` for the reason the kind exists: this | |
| 621 | + | // decides whether the original survives the edit, so both answers have to | |
| 622 | + | // be readable without opening anything. The label the section used to draw | |
| 623 | + | // itself is the field's now. | |
| 624 | + | let result_field = makeover_layout::Field::radio("edit_result", "Result", &RESULT_MODES); | |
| 605 | 625 | ||
| 606 | - | let mut mode = state.edit.result_mode; | |
| 607 | - | ui.horizontal(|ui| { | |
| 608 | - | if ui | |
| 609 | - | .radio_value(&mut mode, Some(EditResultMode::Replace), "Replace original") | |
| 610 | - | .changed() | |
| 611 | - | && let Some(m) = mode | |
| 612 | - | { | |
| 613 | - | state.set_edit_result_mode(m); | |
| 614 | - | } | |
| 615 | - | if ui | |
| 616 | - | .radio_value(&mut mode, Some(EditResultMode::Sibling), "Create sibling") | |
| 617 | - | .changed() | |
| 618 | - | && let Some(m) = mode | |
| 619 | - | { | |
| 620 | - | state.set_edit_result_mode(m); | |
| 621 | - | } | |
| 622 | - | }); | |
| 626 | + | // No mode chosen yet is a value no option carries, which leaves both | |
| 627 | + | // buttons unfilled — the same thing the `Option<EditResultMode>` radios | |
| 628 | + | // showed, and what the prompt after the first edit is there to resolve. | |
| 629 | + | let mut value = state | |
| 630 | + | .edit | |
| 631 | + | .result_mode | |
| 632 | + | .map(EditResultMode::as_value) | |
| 633 | + | .unwrap_or_default() | |
| 634 | + | .to_string(); | |
| 635 | + | widgets::field( | |
| 636 | + | ui, | |
| 637 | + | &result_field, | |
| 638 | + | makeover_immediate::Filling::Text(&mut value), | |
| 639 | + | None, | |
| 640 | + | ); | |
| 641 | + | // Written back on a real change only: `set_edit_result_mode` reaches the | |
| 642 | + | // config store, so re-deriving it every frame the way the import strategy | |
| 643 | + | // does would write on every frame. | |
| 644 | + | if let Some(picked) = EditResultMode::from_value(&value) | |
| 645 | + | && Some(picked) != state.edit.result_mode | |
| 646 | + | { | |
| 647 | + | state.set_edit_result_mode(picked); | |
| 648 | + | } | |
| 623 | 649 | // Replace mode advisory. The previous "no in-app undo" copy was retired | |
| 624 | 650 | // when the inline Undo affordance landed (C-1 part 2). The hint still | |
| 625 | 651 | // points the user at Create sibling as the non-destructive default for | |
| @@ -698,3 +724,43 @@ | |||
| 698 | 724 | }); | |
| 699 | 725 | }); | |
| 700 | 726 | } | |
| 727 | + | ||
| 728 | + | #[cfg(test)] | |
| 729 | + | mod tests { | |
| 730 | + | use super::*; | |
| 731 | + | ||
| 732 | + | /// The pairing the described field rests on: what the radio submits is what | |
| 733 | + | /// `ConfigKey::EditResultMode` stores, so a mode picked in one session is | |
| 734 | + | /// the mode selected on the next open. Asserted rather than assumed | |
| 735 | + | /// because the pairing used to be two string literals kept in step by | |
| 736 | + | /// hand. | |
| 737 | + | #[test] | |
| 738 | + | fn every_offered_answer_is_a_stored_mode() { | |
| 739 | + | for choice in &RESULT_MODES { | |
| 740 | + | let mode = EditResultMode::from_value(choice.value) | |
| 741 | + | .expect("an offered value the app cannot read back is a value it will drop"); | |
| 742 | + | assert_eq!(mode.as_value(), choice.value); | |
| 743 | + | } | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | /// Both modes are offered. A mode the field does not name is unreachable | |
| 747 | + | /// from the panel, whatever the config store holds. | |
| 748 | + | #[test] | |
| 749 | + | fn both_modes_are_offered() { | |
| 750 | + | for mode in [EditResultMode::Replace, EditResultMode::Sibling] { | |
| 751 | + | assert!( | |
| 752 | + | RESULT_MODES.iter().any(|c| c.value == mode.as_value()), | |
| 753 | + | "{} is not offered by the Result field", | |
| 754 | + | mode.as_value() | |
| 755 | + | ); | |
| 756 | + | } | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | /// No mode chosen yet is a value no option carries, which is what leaves | |
| 760 | + | /// both buttons unfilled instead of silently answering the question. | |
| 761 | + | #[test] | |
| 762 | + | fn the_unchosen_state_matches_no_option() { | |
| 763 | + | assert!(EditResultMode::from_value("").is_none()); | |
| 764 | + | assert!(!RESULT_MODES.iter().any(|c| c.value.is_empty())); | |
| 765 | + | } | |
| 766 | + | } |