max / audiofiles
6 files changed,
+325 insertions,
-158 deletions
| @@ -60,6 +60,7 @@ | |||
| 60 | 60 | priority: 0, | |
| 61 | 61 | created_at: 0, | |
| 62 | 62 | match_count: None, | |
| 63 | + | save_refused: false, | |
| 63 | 64 | }); | |
| 64 | 65 | } | |
| 65 | 66 | ||
| @@ -75,6 +76,7 @@ | |||
| 75 | 76 | priority: rule.priority, | |
| 76 | 77 | created_at: rule.created_at, | |
| 77 | 78 | match_count: None, | |
| 79 | + | save_refused: false, | |
| 78 | 80 | }); | |
| 79 | 81 | } | |
| 80 | 82 | ||
| @@ -122,6 +124,12 @@ | |||
| 122 | 124 | }; | |
| 123 | 125 | if draft.name.trim().is_empty() { | |
| 124 | 126 | self.status = "Name the rule before saving.".to_string(); | |
| 127 | + | // Also on the field, which is where the user is looking. The status | |
| 128 | + | // line is at the other end of the window and says nothing about | |
| 129 | + | // which of the form's inputs it means. | |
| 130 | + | if let Some(draft) = self.classifier.editing.as_mut() { | |
| 131 | + | draft.save_refused = true; | |
| 132 | + | } | |
| 125 | 133 | return; | |
| 126 | 134 | } | |
| 127 | 135 | let result = match draft.id { |
| @@ -401,6 +401,14 @@ | |||
| 401 | 401 | pub created_at: i64, | |
| 402 | 402 | /// Cached dry-run match count (recomputed when the draft changes). | |
| 403 | 403 | pub match_count: Option<usize>, | |
| 404 | + | /// Whether Save has been pressed on this draft and refused. | |
| 405 | + | /// | |
| 406 | + | /// What the name field's error is gated on. A form that shows "this is | |
| 407 | + | /// required" before the user has done anything is scolding an empty form; | |
| 408 | + | /// one that shows it after Save declined is answering the question the user | |
| 409 | + | /// just asked. The refusal itself lives in `classifier_save_draft`, which | |
| 410 | + | /// sets this rather than only writing to the status line. | |
| 411 | + | pub save_refused: bool, | |
| 404 | 412 | } | |
| 405 | 413 | ||
| 406 | 414 | /// VFS navigation: the vault list, current location, folder contents, row |
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | //! reconciliation go through the `classifier_*` methods on `BrowserState`. | |
| 4 | 4 | ||
| 5 | 5 | use egui; | |
| 6 | + | use makeover_immediate; | |
| 7 | + | use makeover_layout; | |
| 6 | 8 | ||
| 7 | 9 | use audiofiles_core::rules::{MatchMode, RuleAction, RuleCondition, RuleField, RuleOp}; | |
| 8 | 10 | ||
| @@ -345,10 +347,32 @@ | |||
| 345 | 347 | return; | |
| 346 | 348 | }; | |
| 347 | 349 | ||
| 348 | - | ui.horizontal(|ui| { | |
| 349 | - | ui.label("Name"); | |
| 350 | - | ui.text_edit_singleline(&mut draft.name); | |
| 351 | - | }); | |
| 350 | + | // A described field: a labelled question the form refuses to submit | |
| 351 | + | // without, and the one input here that had no well at all — a bare | |
| 352 | + | // `text_edit_singleline` next to every other text input in the app | |
| 353 | + | // being a well. The error is `save_refused` rather than "empty" so a | |
| 354 | + | // freshly opened editor is not already complaining. | |
| 355 | + | let name_field = makeover_layout::Field { | |
| 356 | + | required: true, | |
| 357 | + | error: (draft.save_refused && draft.name.trim().is_empty()) | |
| 358 | + | .then_some("Name the rule before saving."), | |
| 359 | + | placeholder: Some("Kick drums"), | |
| 360 | + | ..makeover_layout::Field::new(makeover_layout::FieldKind::Text, "rule_name", "Name") | |
| 361 | + | }; | |
| 362 | + | if widgets::field( | |
| 363 | + | ui, | |
| 364 | + | &name_field, | |
| 365 | + | makeover_immediate::Filling::Text(&mut draft.name), | |
| 366 | + | None, | |
| 367 | + | ) | |
| 368 | + | .is_some_and(|response| response.changed()) | |
| 369 | + | { | |
| 370 | + | draft.save_refused = false; | |
| 371 | + | } | |
| 372 | + | ui.add_space(theme::space::bound()); | |
| 373 | + | // A bare toggle, not a field: no help, no validation, no options, and | |
| 374 | + | // under `field()` it would be a checkbox plus a required marker at | |
| 375 | + | // three times the source. | |
| 352 | 376 | ui.checkbox(&mut draft.enabled, "Enabled"); | |
| 353 | 377 | ||
| 354 | 378 | ui.horizontal(|ui| { |
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow. | |
| 2 | 2 | ||
| 3 | 3 | use egui; | |
| 4 | + | use makeover_immediate; | |
| 5 | + | use makeover_layout; | |
| 4 | 6 | use tracing::{error, warn}; | |
| 5 | 7 | ||
| 6 | 8 | use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus}; | |
| @@ -30,28 +32,60 @@ | |||
| 30 | 32 | } | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | - | /// Decide whether the encryption form can submit, and the inline hint to show. | |
| 35 | + | /// Whether the encryption form can submit, and what is wrong with which field. | |
| 36 | + | /// | |
| 37 | + | /// The two messages are held apart rather than returned as one hint because | |
| 38 | + | /// they belong to different questions: a password too short is wrong with the | |
| 39 | + | /// password, and a mismatch is only ever wrong with the confirmation. A | |
| 40 | + | /// described field carries its own error, so a single message would have to be | |
| 41 | + | /// drawn detached from both fields to stay truthful — which is what this form | |
| 42 | + | /// did before it was described. | |
| 43 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 44 | + | struct EncryptionForm { | |
| 45 | + | can_submit: bool, | |
| 46 | + | password_error: Option<&'static str>, | |
| 47 | + | confirm_error: Option<&'static str>, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | impl EncryptionForm { | |
| 51 | + | const SILENT: Self = Self { | |
| 52 | + | can_submit: false, | |
| 53 | + | password_error: None, | |
| 54 | + | confirm_error: None, | |
| 55 | + | }; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Decide whether the encryption form can submit, and which field is wrong. | |
| 34 | 59 | /// Unlock (`has_server_key`) only needs a non-empty password. First-time setup | |
| 35 | - | /// requires a >=8-char password confirmed by a matching second entry; the hint | |
| 36 | - | /// is `None` while the user is still mid-entry (so no error flashes prematurely). | |
| 37 | - | fn encryption_submit_state( | |
| 38 | - | has_server_key: bool, | |
| 39 | - | pw: &str, | |
| 40 | - | confirm: &str, | |
| 41 | - | ) -> (bool, Option<&'static str>) { | |
| 60 | + | /// requires a >=8-char password confirmed by a matching second entry; both | |
| 61 | + | /// errors stay `None` while the user is still mid-entry (so no error flashes | |
| 62 | + | /// prematurely). | |
| 63 | + | fn encryption_submit_state(has_server_key: bool, pw: &str, confirm: &str) -> EncryptionForm { | |
| 42 | 64 | if has_server_key { | |
| 43 | - | return (!pw.is_empty(), None); | |
| 65 | + | return EncryptionForm { | |
| 66 | + | can_submit: !pw.is_empty(), | |
| 67 | + | ..EncryptionForm::SILENT | |
| 68 | + | }; | |
| 44 | 69 | } | |
| 45 | 70 | if pw.is_empty() { | |
| 46 | - | (false, None) | |
| 71 | + | EncryptionForm::SILENT | |
| 47 | 72 | } else if pw.len() < 8 { | |
| 48 | - | (false, Some("Password must be at least 8 characters.")) | |
| 73 | + | EncryptionForm { | |
| 74 | + | password_error: Some("Password must be at least 8 characters."), | |
| 75 | + | ..EncryptionForm::SILENT | |
| 76 | + | } | |
| 49 | 77 | } else if confirm.is_empty() { | |
| 50 | - | (false, None) | |
| 78 | + | EncryptionForm::SILENT | |
| 51 | 79 | } else if pw != confirm { | |
| 52 | - | (false, Some("Passwords don't match.")) | |
| 80 | + | EncryptionForm { | |
| 81 | + | confirm_error: Some("Passwords don't match."), | |
| 82 | + | ..EncryptionForm::SILENT | |
| 83 | + | } | |
| 53 | 84 | } else { | |
| 54 | - | (true, None) | |
| 85 | + | EncryptionForm { | |
| 86 | + | can_submit: true, | |
| 87 | + | ..EncryptionForm::SILENT | |
| 88 | + | } | |
| 55 | 89 | } | |
| 56 | 90 | } | |
| 57 | 91 | ||
| @@ -446,46 +480,50 @@ | |||
| 446 | 480 | } | |
| 447 | 481 | ||
| 448 | 482 | ui.add_space(theme::space::group()); | |
| 449 | - | ui.horizontal(|ui| { | |
| 450 | - | ui.label("Password:"); | |
| 451 | - | widgets::text_field( | |
| 452 | - | ui, | |
| 453 | - | egui::TextEdit::singleline(&mut state.sync.encryption_input) | |
| 454 | - | .password(true) | |
| 455 | - | .desired_width(200.0), | |
| 456 | - | ); | |
| 457 | - | }); | |
| 458 | 483 | ||
| 459 | - | // First-time setup: confirm field + length gate. The unlock path doesn't | |
| 460 | - | // need confirmation, a typo there is recoverable (just re-enter). | |
| 461 | - | let (can_submit, hint): (bool, Option<&str>) = if has_server_key { | |
| 462 | - | encryption_submit_state(true, &state.sync.encryption_input, "") | |
| 463 | - | } else { | |
| 464 | - | ui.add_space(theme::space::bound()); | |
| 465 | - | ui.horizontal(|ui| { | |
| 466 | - | ui.label("Confirm: "); | |
| 467 | - | widgets::text_field( | |
| 468 | - | ui, | |
| 469 | - | egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input) | |
| 470 | - | .password(true) | |
| 471 | - | .desired_width(200.0), | |
| 472 | - | ); | |
| 473 | - | }); | |
| 474 | - | encryption_submit_state( | |
| 475 | - | false, | |
| 476 | - | &state.sync.encryption_input, | |
| 477 | - | &state.sync.encryption_confirm_input, | |
| 484 | + | // A described form, and the app's clearest case for one: two labelled | |
| 485 | + | // questions the form refuses to submit without, each with its own rule | |
| 486 | + | // about what a wrong answer is. `Secret` rather than `Text` for both -- | |
| 487 | + | // the renderer masks a confidential kind without being asked, which is | |
| 488 | + | // what makes the masking a property of the question here rather than a | |
| 489 | + | // flag every call site has to remember. | |
| 490 | + | // | |
| 491 | + | // First-time setup gets the confirmation; the unlock path does not, because | |
| 492 | + | // a typo there is recoverable by re-entering. | |
| 493 | + | let form = encryption_submit_state( | |
| 494 | + | has_server_key, | |
| 495 | + | &state.sync.encryption_input, | |
| 496 | + | &state.sync.encryption_confirm_input, | |
| 497 | + | ); | |
| 498 | + | let mut fields = vec![makeover_layout::Field { | |
| 499 | + | required: true, | |
| 500 | + | error: form.password_error, | |
| 501 | + | ..makeover_layout::Field::new( | |
| 502 | + | makeover_layout::FieldKind::Secret, | |
| 503 | + | "encryption_password", | |
| 504 | + | "Password", | |
| 478 | 505 | ) | |
| 479 | - | }; | |
| 480 | - | ||
| 481 | - | if let Some(msg) = hint { | |
| 482 | - | ui.add_space(theme::space::hair()); | |
| 483 | - | ui.label( | |
| 484 | - | egui::RichText::new(msg) | |
| 485 | - | .small() | |
| 486 | - | .color(theme::content_muted()), | |
| 487 | - | ); | |
| 506 | + | }]; | |
| 507 | + | if !has_server_key { | |
| 508 | + | fields.push(makeover_layout::Field { | |
| 509 | + | required: true, | |
| 510 | + | error: form.confirm_error, | |
| 511 | + | ..makeover_layout::Field::new( | |
| 512 | + | makeover_layout::FieldKind::Secret, | |
| 513 | + | "encryption_confirm", | |
| 514 | + | "Confirm password", | |
| 515 | + | ) | |
| 516 | + | }); | |
| 488 | 517 | } | |
| 518 | + | widgets::group(ui, &fields, false, |ui, field| { | |
| 519 | + | let filling = if field.name == "encryption_password" { | |
| 520 | + | makeover_immediate::Filling::Text(&mut state.sync.encryption_input) | |
| 521 | + | } else { | |
| 522 | + | makeover_immediate::Filling::Text(&mut state.sync.encryption_confirm_input) | |
| 523 | + | }; | |
| 524 | + | widgets::field(ui, field, filling, None); | |
| 525 | + | }); | |
| 526 | + | let can_submit = form.can_submit; | |
| 489 | 527 | ||
| 490 | 528 | ui.add_space(theme::space::peer()); | |
| 491 | 529 | let button_label = if has_server_key { | |
| @@ -784,17 +822,29 @@ | |||
| 784 | 822 | ||
| 785 | 823 | #[test] | |
| 786 | 824 | fn unlock_needs_only_a_nonempty_password() { | |
| 787 | - | assert_eq!(encryption_submit_state(true, "", ""), (false, None)); | |
| 788 | - | assert_eq!(encryption_submit_state(true, "x", ""), (true, None)); | |
| 825 | + | assert_eq!( | |
| 826 | + | encryption_submit_state(true, "", ""), | |
| 827 | + | EncryptionForm::SILENT | |
| 828 | + | ); | |
| 829 | + | assert_eq!( | |
| 830 | + | encryption_submit_state(true, "x", ""), | |
| 831 | + | EncryptionForm { | |
| 832 | + | can_submit: true, | |
| 833 | + | ..EncryptionForm::SILENT | |
| 834 | + | } | |
| 835 | + | ); | |
| 789 | 836 | } | |
| 790 | 837 | ||
| 791 | 838 | #[test] | |
| 792 | 839 | fn setup_holds_hint_silent_until_a_rule_is_broken() { | |
| 793 | 840 | // Empty password and empty confirm are mid-entry states, not errors. | |
| 794 | - | assert_eq!(encryption_submit_state(false, "", ""), (false, None)); | |
| 841 | + | assert_eq!( | |
| 842 | + | encryption_submit_state(false, "", ""), | |
| 843 | + | EncryptionForm::SILENT | |
| 844 | + | ); | |
| 795 | 845 | assert_eq!( | |
| 796 | 846 | encryption_submit_state(false, "longenough", ""), | |
| 797 | - | (false, None) | |
| 847 | + | EncryptionForm::SILENT | |
| 798 | 848 | ); | |
| 799 | 849 | } | |
| 800 | 850 | ||
| @@ -802,15 +852,37 @@ | |||
| 802 | 852 | fn setup_enforces_length_and_match() { | |
| 803 | 853 | assert_eq!( | |
| 804 | 854 | encryption_submit_state(false, "short", "short"), | |
| 805 | - | (false, Some("Password must be at least 8 characters.")), | |
| 855 | + | EncryptionForm { | |
| 856 | + | password_error: Some("Password must be at least 8 characters."), | |
| 857 | + | ..EncryptionForm::SILENT | |
| 858 | + | }, | |
| 806 | 859 | ); | |
| 807 | 860 | assert_eq!( | |
| 808 | 861 | encryption_submit_state(false, "longenough", "different"), | |
| 809 | - | (false, Some("Passwords don't match.")), | |
| 862 | + | EncryptionForm { | |
| 863 | + | confirm_error: Some("Passwords don't match."), | |
| 864 | + | ..EncryptionForm::SILENT | |
| 865 | + | }, | |
| 810 | 866 | ); | |
| 811 | 867 | assert_eq!( | |
| 812 | 868 | encryption_submit_state(false, "longenough", "longenough"), | |
| 813 | - | (true, None) | |
| 869 | + | EncryptionForm { | |
| 870 | + | can_submit: true, | |
| 871 | + | ..EncryptionForm::SILENT | |
| 872 | + | } | |
| 814 | 873 | ); | |
| 815 | 874 | } | |
| 875 | + | ||
| 876 | + | /// Each message belongs to the field it is about. A single hint could only | |
| 877 | + | /// sit under both, which is what the described form replaced. | |
| 878 | + | #[test] | |
| 879 | + | fn a_mismatch_is_wrong_with_the_confirmation_not_the_password() { | |
| 880 | + | let form = encryption_submit_state(false, "longenough", "different"); | |
| 881 | + | assert_eq!(form.password_error, None); | |
| 882 | + | assert!(form.confirm_error.is_some()); | |
| 883 | + | ||
| 884 | + | let form = encryption_submit_state(false, "short", "short"); | |
| 885 | + | assert!(form.password_error.is_some()); | |
| 886 | + | assert_eq!(form.confirm_error, None); | |
| 887 | + | } | |
| 816 | 888 | } |
| @@ -451,6 +451,25 @@ | |||
| 451 | 451 | makeover_immediate::field(ui, field, filling, state, &theme::palette(), &field_style()) | |
| 452 | 452 | } | |
| 453 | 453 | ||
| 454 | + | /// A set of described fields laid down a column, at this app's field geometry. | |
| 455 | + | /// | |
| 456 | + | /// [`field`]'s wrapper for the same reason, and one more: the gap between one | |
| 457 | + | /// field and the next is `field_style().group_gap`, so a form that lays its own | |
| 458 | + | /// fields out with `add_space` picks a number instead of using the one the | |
| 459 | + | /// style already names. Drawing the set through here is what keeps the spacing | |
| 460 | + | /// inside a form and the spacing between forms from drifting apart. | |
| 461 | + | /// | |
| 462 | + | /// `draw` is called once per visible field, in order; `show_extended` is the | |
| 463 | + | /// form's disclosure, which the app owns rather than any one field. | |
| 464 | + | pub fn group<'a>( | |
| 465 | + | ui: &mut egui::Ui, | |
| 466 | + | fields: &'a [makeover_layout::Field<'a>], | |
| 467 | + | show_extended: bool, | |
| 468 | + | draw: impl FnMut(&mut egui::Ui, &'a makeover_layout::Field<'a>), | |
| 469 | + | ) { | |
| 470 | + | makeover_immediate::group(ui, fields, show_extended, &field_style(), draw); | |
| 471 | + | } | |
| 472 | + | ||
| 454 | 473 | /// Inline informational banner: raised card, body text in `content_secondary`. | |
| 455 | 474 | /// Used for one-time tips and unobtrusive panel notices. | |
| 456 | 475 | pub fn info_banner(ui: &mut egui::Ui, body: &str) { |
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use super::super::{theme, widgets}; | |
| 4 | 4 | use egui; | |
| 5 | + | use makeover_immediate; | |
| 6 | + | use makeover_layout; | |
| 5 | 7 | ||
| 6 | 8 | use crate::import::ImportStrategy; | |
| 7 | 9 | use crate::state::{BrowserState, ImportMode}; | |
| @@ -58,110 +60,144 @@ | |||
| 58 | 60 | ); | |
| 59 | 61 | ui.add_space(theme::space::group()); | |
| 60 | 62 | ||
| 61 | - | ui.label("Import strategy:"); | |
| 62 | - | ui.add_space(theme::space::bound()); | |
| 63 | - | ||
| 64 | - | let is_flat = matches!( | |
| 65 | - | &state.import_wf.import_mode, | |
| 66 | - | ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. } | |
| 67 | - | ); | |
| 68 | - | let is_new_vfs = matches!( | |
| 69 | - | &state.import_wf.import_mode, | |
| 70 | - | ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. } | |
| 71 | - | ); | |
| 72 | - | let is_merge = matches!( | |
| 73 | - | &state.import_wf.import_mode, | |
| 74 | - | ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. } | |
| 75 | - | ); | |
| 63 | + | // The strategy and the one input it opens are a described form, drawn | |
| 64 | + | // as a `group` so the spacing between the question and its follow-up is | |
| 65 | + | // the field style's own `group_gap` rather than a number chosen here. | |
| 66 | + | // | |
| 67 | + | // A `Radio` and not a `Select`, for the reason the kind exists: this | |
| 68 | + | // decides where every imported file lands and it cannot be revised | |
| 69 | + | // afterwards without re-importing, so the three answers have to be | |
| 70 | + | // readable without opening anything. | |
| 71 | + | const STRATEGIES: [makeover_layout::Choice<'static>; 3] = [ | |
| 72 | + | makeover_layout::Choice { | |
| 73 | + | value: "flat", | |
| 74 | + | label: "Flat (all files in current directory)", | |
| 75 | + | }, | |
| 76 | + | makeover_layout::Choice { | |
| 77 | + | value: "new", | |
| 78 | + | label: "New vault (preserve directory structure)", | |
| 79 | + | }, | |
| 80 | + | makeover_layout::Choice { | |
| 81 | + | value: "merge", | |
| 82 | + | label: "Merge into existing vault", | |
| 83 | + | }, | |
| 84 | + | ]; | |
| 76 | 85 | ||
| 86 | + | // The description names its answers by string and this app holds a | |
| 87 | + | // strategy enum, a name and an index, so the two are marshalled here | |
| 88 | + | // rather than by reshaping the state. Same division as the storage-style | |
| 89 | + | // choice in `settings_panel`: the question is described, the app's | |
| 90 | + | // encoding of the answer stays private. | |
| 77 | 91 | let current_vfs_id = state.current_vfs_id(); | |
| 78 | 92 | let current_dir = state.nav.current_dir; | |
| 79 | - | if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat | |
| 80 | - | && let (ImportMode::ConfigureImport { strategy, .. }, Some(vfs_id)) = | |
| 81 | - | (&mut state.import_wf.import_mode, current_vfs_id) | |
| 82 | - | { | |
| 83 | - | *strategy = ImportStrategy::Flat { | |
| 84 | - | vfs_id, | |
| 85 | - | parent_id: current_dir, | |
| 86 | - | }; | |
| 87 | - | } | |
| 93 | + | let ImportMode::ConfigureImport { | |
| 94 | + | strategy, | |
| 95 | + | new_vfs_name, | |
| 96 | + | available_vfs, | |
| 97 | + | selected_merge_vfs_idx, | |
| 98 | + | .. | |
| 99 | + | } = &state.import_wf.import_mode | |
| 100 | + | else { | |
| 101 | + | return; | |
| 102 | + | }; | |
| 103 | + | let mut strategy_value = String::from(match strategy { | |
| 104 | + | ImportStrategy::Flat { .. } => "flat", | |
| 105 | + | ImportStrategy::NewVfs { .. } => "new", | |
| 106 | + | ImportStrategy::MergeIntoVfs { .. } => "merge", | |
| 107 | + | }); | |
| 108 | + | let mut vault_name = new_vfs_name.clone(); | |
| 109 | + | let mut merge_value = selected_merge_vfs_idx.to_string(); | |
| 110 | + | let no_vaults = available_vfs.is_empty(); | |
| 111 | + | // Owned first, borrowed second: `Choice` holds `&str`, and the vault | |
| 112 | + | // names live behind the same borrow of `state` the fillings need | |
| 113 | + | // mutably. | |
| 114 | + | let vault_options: Vec<(String, String)> = available_vfs | |
| 115 | + | .iter() | |
| 116 | + | .enumerate() | |
| 117 | + | .map(|(i, vfs)| (i.to_string(), vfs.name.clone())) | |
| 118 | + | .collect(); | |
| 119 | + | let vault_choices: Vec<makeover_layout::Choice<'_>> = vault_options | |
| 120 | + | .iter() | |
| 121 | + | .map(|(value, label)| makeover_layout::Choice { value, label }) | |
| 122 | + | .collect(); | |
| 88 | 123 | ||
| 89 | - | if ui.radio(is_new_vfs, "New vault (preserve directory structure)").clicked() && !is_new_vfs | |
| 90 | - | && let ImportMode::ConfigureImport { | |
| 91 | - | ref mut strategy, | |
| 92 | - | ref new_vfs_name, | |
| 93 | - | .. | |
| 94 | - | } = state.import_wf.import_mode | |
| 95 | - | { | |
| 96 | - | *strategy = ImportStrategy::NewVfs { | |
| 97 | - | vfs_name: new_vfs_name.clone(), | |
| 98 | - | }; | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | if is_new_vfs { | |
| 102 | - | ui.indent("new_vfs_indent", |ui| { | |
| 103 | - | ui.horizontal(|ui| { | |
| 104 | - | ui.label("Vault name:"); | |
| 105 | - | if let ImportMode::ConfigureImport { | |
| 106 | - | ref mut new_vfs_name, | |
| 107 | - | ref mut strategy, | |
| 108 | - | .. | |
| 109 | - | } = state.import_wf.import_mode | |
| 110 | - | && ui.text_edit_singleline(new_vfs_name).changed() { | |
| 111 | - | *strategy = ImportStrategy::NewVfs { | |
| 112 | - | vfs_name: new_vfs_name.clone(), | |
| 113 | - | }; | |
| 114 | - | } | |
| 115 | - | }); | |
| 116 | - | }); | |
| 124 | + | let mut fields = vec![makeover_layout::Field::radio( | |
| 125 | + | "import_strategy", | |
| 126 | + | "Import strategy", | |
| 127 | + | &STRATEGIES, | |
| 128 | + | )]; | |
| 129 | + | match strategy_value.as_str() { | |
| 130 | + | // The error is the state the old screen left unexplained: a new | |
| 131 | + | // vault chosen and no name, where Import disables itself and says | |
| 132 | + | // why only to a pointer that hovers it. | |
| 133 | + | "new" => fields.push(makeover_layout::Field { | |
| 134 | + | required: true, | |
| 135 | + | placeholder: Some("Drum Kits"), | |
| 136 | + | error: vault_name | |
| 137 | + | .trim() | |
| 138 | + | .is_empty() | |
| 139 | + | .then_some("Enter a name for the new vault."), | |
| 140 | + | ..makeover_layout::Field::new( | |
| 141 | + | makeover_layout::FieldKind::Text, | |
| 142 | + | "new_vault_name", | |
| 143 | + | "Vault name", | |
| 144 | + | ) | |
| 145 | + | }), | |
| 146 | + | "merge" => fields.push(makeover_layout::Field { | |
| 147 | + | error: no_vaults.then_some("No existing vaults to merge into."), | |
| 148 | + | ..makeover_layout::Field::select("merge_vault", "Merge into", &vault_choices) | |
| 149 | + | }), | |
| 150 | + | _ => {} | |
| 117 | 151 | } | |
| 118 | 152 | ||
| 119 | - | if ui.radio(is_merge, "Merge into existing vault").clicked() && !is_merge | |
| 120 | - | && let ImportMode::ConfigureImport { | |
| 121 | - | ref mut strategy, | |
| 122 | - | ref available_vfs, | |
| 123 | - | selected_merge_vfs_idx, | |
| 124 | - | .. | |
| 125 | - | } = state.import_wf.import_mode | |
| 126 | - | && let Some(vfs) = available_vfs.get(selected_merge_vfs_idx) | |
| 127 | - | { | |
| 128 | - | *strategy = ImportStrategy::MergeIntoVfs { | |
| 153 | + | widgets::group(ui, &fields, false, |ui, field| { | |
| 154 | + | let filling = match field.name { | |
| 155 | + | "import_strategy" => makeover_immediate::Filling::Text(&mut strategy_value), | |
| 156 | + | "new_vault_name" => makeover_immediate::Filling::Text(&mut vault_name), | |
| 157 | + | _ => makeover_immediate::Filling::Text(&mut merge_value), | |
| 158 | + | }; | |
| 159 | + | widgets::field(ui, field, filling, None); | |
| 160 | + | }); | |
| 161 | + | ||
| 162 | + | // Back the other way. Unconditional rather than gated on `.changed()`: | |
| 163 | + | // the strategy is derived from the three answers, so re-deriving it | |
| 164 | + | // every frame keeps it correct without every edit site having to | |
| 165 | + | // remember to rebuild it — which the vault-name edit was the only site | |
| 166 | + | // that did. | |
| 167 | + | let ImportMode::ConfigureImport { | |
| 168 | + | strategy, | |
| 169 | + | new_vfs_name, | |
| 170 | + | available_vfs, | |
| 171 | + | selected_merge_vfs_idx, | |
| 172 | + | .. | |
| 173 | + | } = &mut state.import_wf.import_mode | |
| 174 | + | else { | |
| 175 | + | return; | |
| 176 | + | }; | |
| 177 | + | new_vfs_name.clone_from(&vault_name); | |
| 178 | + | if let Ok(i) = merge_value.parse::<usize>() { | |
| 179 | + | *selected_merge_vfs_idx = i; | |
| 180 | + | } | |
| 181 | + | // A strategy the app cannot form yet (no current vault to import flat | |
| 182 | + | // into, no vault to merge with) leaves the previous one standing, and | |
| 183 | + | // the radio reads back from it on the next frame. | |
| 184 | + | let next = match strategy_value.as_str() { | |
| 185 | + | "flat" => current_vfs_id.map(|vfs_id| ImportStrategy::Flat { | |
| 186 | + | vfs_id, | |
| 187 | + | parent_id: current_dir, | |
| 188 | + | }), | |
| 189 | + | "new" => Some(ImportStrategy::NewVfs { | |
| 190 | + | vfs_name: new_vfs_name.clone(), | |
| 191 | + | }), | |
| 192 | + | _ => available_vfs | |
| 193 | + | .get(*selected_merge_vfs_idx) | |
| 194 | + | .map(|vfs| ImportStrategy::MergeIntoVfs { | |
| 129 | 195 | vfs_id: vfs.id, | |
| 130 | 196 | parent_id: None, | |
| 131 | - | }; | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | if is_merge { | |
| 135 | - | ui.indent("merge_vfs_indent", |ui| { | |
| 136 | - | if let ImportMode::ConfigureImport { | |
| 137 | - | ref mut strategy, | |
| 138 | - | ref available_vfs, | |
| 139 | - | ref mut selected_merge_vfs_idx, | |
| 140 | - | .. | |
| 141 | - | } = state.import_wf.import_mode | |
| 142 | - | { | |
| 143 | - | let current_name = available_vfs | |
| 144 | - | .get(*selected_merge_vfs_idx) | |
| 145 | - | .map_or("(none)", |v| v.name.as_str()); | |
| 146 | - | ||
| 147 | - | egui::ComboBox::from_id_salt("merge_vfs_select") | |
| 148 | - | .selected_text(current_name) | |
| 149 | - | .show_ui(ui, |ui| { | |
| 150 | - | for (i, vfs) in available_vfs.iter().enumerate() { | |
| 151 | - | if ui | |
| 152 | - | .selectable_label(i == *selected_merge_vfs_idx, &vfs.name) | |
| 153 | - | .clicked() | |
| 154 | - | { | |
| 155 | - | *selected_merge_vfs_idx = i; | |
| 156 | - | *strategy = ImportStrategy::MergeIntoVfs { | |
| 157 | - | vfs_id: vfs.id, | |
| 158 | - | parent_id: None, | |
| 159 | - | }; | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | }); | |
| 163 | - | } | |
| 164 | - | }); | |
| 197 | + | }), | |
| 198 | + | }; | |
| 199 | + | if let Some(next) = next { | |
| 200 | + | *strategy = next; | |
| 165 | 201 | } | |
| 166 | 202 | ||
| 167 | 203 | ui.add_space(theme::space::section()); |