max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+237 insertions,
-203 deletions
| @@ -141,17 +141,22 @@ | |||
| 141 | 141 | if state.overlay.show_help { | |
| 142 | 142 | overlays::draw_help_overlay(ctx, state); | |
| 143 | 143 | } | |
| 144 | + | // The four name modals, all of them `quasi::naming`. Which one is showing is | |
| 145 | + | // still the app's own flag, so the address is built here rather than read | |
| 146 | + | // off anything a route knows. | |
| 144 | 147 | if state.vfs_modal.show_vfs_create { | |
| 145 | - | overlays::draw_vfs_create_modal(ctx, state); | |
| 148 | + | crate::quasi::panel::draw_naming(ctx, state, "New Vault", "/vaults/new"); | |
| 146 | 149 | } | |
| 147 | - | if state.vfs_modal.vfs_rename_target.is_some() { | |
| 148 | - | overlays::draw_vfs_rename_modal(ctx, state); | |
| 150 | + | if let Some((id, _)) = &state.vfs_modal.vfs_rename_target { | |
| 151 | + | let home = format!("/vaults/{}/rename", id.as_i64()); | |
| 152 | + | crate::quasi::panel::draw_naming(ctx, state, "Rename Vault", &home); | |
| 149 | 153 | } | |
| 150 | 154 | if state.vfs_modal.show_dir_create { | |
| 151 | - | overlays::draw_dir_create_modal(ctx, state); | |
| 155 | + | crate::quasi::panel::draw_naming(ctx, state, "New Folder", "/folders/new"); | |
| 152 | 156 | } | |
| 153 | - | if state.vfs_modal.dir_rename_target.is_some() { | |
| 154 | - | overlays::draw_dir_rename_modal(ctx, state); | |
| 157 | + | if let Some((id, _)) = &state.vfs_modal.dir_rename_target { | |
| 158 | + | let home = format!("/folders/{}/rename", id.as_i64()); | |
| 159 | + | crate::quasi::panel::draw_naming(ctx, state, "Rename", &home); | |
| 155 | 160 | } | |
| 156 | 161 | if state.loose_files.show_loose_files_warning { | |
| 157 | 162 | crate::quasi::panel::draw_integrity(ctx, state); |
| @@ -748,6 +748,8 @@ | |||
| 748 | 748 | BulkMove(Option<i64>), | |
| 749 | 749 | /// Rename everything chosen by this pattern. | |
| 750 | 750 | BulkRename(String), | |
| 751 | + | /// A name modal is finished with, whichever of the four it was. | |
| 752 | + | NamingDone, | |
| 751 | 753 | /// Re-read the vault list, and say this about why. | |
| 752 | 754 | VaultsChanged(String), | |
| 753 | 755 | /// Re-read the current folder, and say this about why. | |
| @@ -2764,6 +2766,12 @@ | |||
| 2764 | 2766 | /// the app does about a write decides where the write goes*, and here what the | |
| 2765 | 2767 | /// app does about it is two things with different lifetimes. | |
| 2766 | 2768 | pub trait Naming { | |
| 2769 | + | /// The modal is finished with: put it away. | |
| 2770 | + | /// | |
| 2771 | + | /// The host is what keeps one of these on screen, so leaving the address is | |
| 2772 | + | /// not leaving the screen. See `naming`'s `DONE`. | |
| 2773 | + | fn done(&self); | |
| 2774 | + | ||
| 2767 | 2775 | /// What this vault is called, if it is one. | |
| 2768 | 2776 | fn vault(&self, id: i64) -> Option<String>; | |
| 2769 | 2777 | ||
| @@ -2837,6 +2845,10 @@ | |||
| 2837 | 2845 | } | |
| 2838 | 2846 | ||
| 2839 | 2847 | impl Naming for FromNaming<'_> { | |
| 2848 | + | fn done(&self) { | |
| 2849 | + | self.intents.borrow_mut().push(Intent::NamingDone); | |
| 2850 | + | } | |
| 2851 | + | ||
| 2840 | 2852 | fn vault(&self, id: i64) -> Option<String> { | |
| 2841 | 2853 | self.state | |
| 2842 | 2854 | .nav |
| @@ -82,8 +82,21 @@ | |||
| 82 | 82 | /// The main window, which is what all four of these are drawn over. Same finding | |
| 83 | 83 | /// as [`bulk`](super::bulk)'s first: this navigates rather than dismisses, | |
| 84 | 84 | /// because there is no described way to close what is on top. | |
| 85 | + | /// | |
| 86 | + | /// Navigating is not enough on its own, which the flip found (2026-08-22). | |
| 87 | + | /// **What keeps one of these on screen is the host's own flag**, not the | |
| 88 | + | /// runtime's layer stack: `vfs_modal`'s two bools and two targets are what the | |
| 89 | + | /// app checks before drawing anything, so a route that navigates away and says | |
| 90 | + | /// nothing else leaves the window up with the main screen inside it. So every | |
| 91 | + | /// exit goes through [`DONE`] first, which is `integrity`'s `dismiss` in a | |
| 92 | + | /// second consumer: a route whose whole job is to tell the host the screen is | |
| 93 | + | /// finished with. That is the app's answer to the finding above, not the | |
| 94 | + | /// vocabulary's -- an `Outcome` meaning "this overlay is done" is still missing. | |
| 85 | 95 | const BACK: &str = "/"; | |
| 86 | 96 | ||
| 97 | + | /// The route that says a modal is finished with, whichever of the four it was. | |
| 98 | + | const DONE: &str = "/naming/done"; | |
| 99 | + | ||
| 87 | 100 | /// Register the four modals' routes. | |
| 88 | 101 | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 89 | 102 | router | |
| @@ -95,6 +108,7 @@ | |||
| 95 | 108 | .post("/folders/new", new_folder) | |
| 96 | 109 | .get("/folders/{id}/rename", rename_folder_screen) | |
| 97 | 110 | .post("/folders/{id}/rename", rename_folder) | |
| 111 | + | .post(DONE, done) | |
| 98 | 112 | } | |
| 99 | 113 | ||
| 100 | 114 | /// What one of these modals asks. | |
| @@ -124,7 +138,7 @@ | |||
| 124 | 138 | ||
| 125 | 139 | /// `POST /vaults/new` | |
| 126 | 140 | fn new_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 127 | - | submitted(&request, &asking_new_vault(), |name| { | |
| 141 | + | submitted(state, &request, &asking_new_vault(), |name| { | |
| 128 | 142 | state.naming.create_vault(name) | |
| 129 | 143 | }) | |
| 130 | 144 | } | |
| @@ -160,7 +174,7 @@ | |||
| 160 | 174 | /// `POST /vaults/{id}/rename` | |
| 161 | 175 | fn rename_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 162 | 176 | let id = numbered(&request)?; | |
| 163 | - | submitted(&request, &asking_rename_vault(id), |name| { | |
| 177 | + | submitted(state, &request, &asking_rename_vault(id), |name| { | |
| 164 | 178 | state.naming.rename_vault(id, name) | |
| 165 | 179 | }) | |
| 166 | 180 | } | |
| @@ -185,7 +199,7 @@ | |||
| 185 | 199 | ||
| 186 | 200 | /// `POST /folders/new` | |
| 187 | 201 | fn new_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 188 | - | submitted(&request, &asking_new_folder(), |name| { | |
| 202 | + | submitted(state, &request, &asking_new_folder(), |name| { | |
| 189 | 203 | state.naming.create_folder(name) | |
| 190 | 204 | }) | |
| 191 | 205 | } | |
| @@ -216,7 +230,7 @@ | |||
| 216 | 230 | /// `POST /folders/{id}/rename` | |
| 217 | 231 | fn rename_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 218 | 232 | let id = numbered(&request)?; | |
| 219 | - | submitted(&request, &asking_rename_folder(id), |name| { | |
| 233 | + | submitted(state, &request, &asking_rename_folder(id), |name| { | |
| 220 | 234 | state.naming.rename_folder(id, name) | |
| 221 | 235 | }) | |
| 222 | 236 | } | |
| @@ -233,6 +247,14 @@ | |||
| 233 | 247 | } | |
| 234 | 248 | } | |
| 235 | 249 | ||
| 250 | + | /// `POST /naming/done` | |
| 251 | + | /// | |
| 252 | + | /// Tell the host the modal is finished with, then leave. See [`DONE`]. | |
| 253 | + | fn done(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 254 | + | state.naming.done(); | |
| 255 | + | Ok(Response::from(leaving())) | |
| 256 | + | } | |
| 257 | + | ||
| 236 | 258 | /// The id a request names. | |
| 237 | 259 | fn numbered(request: &Request) -> Result<i64, RouteError> { | |
| 238 | 260 | request | |
| @@ -253,16 +275,21 @@ | |||
| 253 | 275 | /// and the reason none of these fields is `required`: the marker would claim a | |
| 254 | 276 | /// refusal that never happens. | |
| 255 | 277 | fn submitted( | |
| 278 | + | state: &Panels<'_>, | |
| 256 | 279 | request: &Request, | |
| 257 | 280 | asking: &Asking, | |
| 258 | 281 | commit: impl FnOnce(&str) -> Result<String, String>, | |
| 259 | 282 | ) -> Result<Response, RouteError> { | |
| 260 | 283 | let typed = request.payload.get(NAME).unwrap_or_default().trim(); | |
| 261 | 284 | if typed.is_empty() { | |
| 285 | + | state.naming.done(); | |
| 262 | 286 | return Ok(Response::from(leaving())); | |
| 263 | 287 | } | |
| 264 | 288 | match commit(typed) { | |
| 265 | - | Ok(say) => Ok(Response::from(leaving()).toast(Tone::Success, say)), | |
| 289 | + | Ok(say) => { | |
| 290 | + | state.naming.done(); | |
| 291 | + | Ok(Response::from(leaving()).toast(Tone::Success, say)) | |
| 292 | + | } | |
| 266 | 293 | // The modal stays up with the name still in it. See the module header: | |
| 267 | 294 | // a fragment rather than a second `Over`. | |
| 268 | 295 | Err(why) => Ok(Response::from(Outcome::Fragment { | |
| @@ -280,7 +307,7 @@ | |||
| 280 | 307 | } | |
| 281 | 308 | let body = body | |
| 282 | 309 | .with(form(asking, value, error)) | |
| 283 | - | .with(Node::Act(Act::new("Cancel", Action::get(BACK)).key("esc"))); | |
| 310 | + | .with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc"))); | |
| 284 | 311 | ||
| 285 | 312 | Response::from(Outcome::Over( | |
| 286 | 313 | Screen::sidebar_content(asking.title).with(body), |
| @@ -60,6 +60,7 @@ | |||
| 60 | 60 | sweep: Option<Runtime>, | |
| 61 | 61 | filters: Option<Runtime>, | |
| 62 | 62 | integrity: Option<Runtime>, | |
| 63 | + | naming: Option<Runtime>, | |
| 63 | 64 | /// Whether the described main window is open. | |
| 64 | 65 | pub show_shell: bool, | |
| 65 | 66 | /// Whether the described detail panel is open. | |
| @@ -489,6 +490,50 @@ | |||
| 489 | 490 | } | |
| 490 | 491 | } | |
| 491 | 492 | ||
| 493 | + | /// Draw one of the four name modals, and act on whatever was pressed. | |
| 494 | + | /// | |
| 495 | + | /// One function for four screens, because they are one screen four times: a | |
| 496 | + | /// field, a submit and a cancel, differing only in what they are called and in | |
| 497 | + | /// what the submit does. The shipped side already knew that, and said it by | |
| 498 | + | /// sharing `handle_name_modal_outcome` between four functions rather than by | |
| 499 | + | /// sharing an address. | |
| 500 | + | /// | |
| 501 | + | /// `title` and `home` come from the caller because the app is what knows which | |
| 502 | + | /// of the four is showing: `vfs_modal`'s two flags and two targets still decide, | |
| 503 | + | /// and those are the host's own state rather than anything a route reads. | |
| 504 | + | /// | |
| 505 | + | /// Refreshed unconditionally. A refusal replaces the form region and the field | |
| 506 | + | /// keeps what was typed, which is `naming`'s `Fragment`, and a screen that is | |
| 507 | + | /// not re-asked shows the answer before it. | |
| 508 | + | pub fn draw_naming(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) { | |
| 509 | + | let intents = RefCell::new(Vec::new()); | |
| 510 | + | let mut runtime = state.described.naming.take(); | |
| 511 | + | let host = Host { | |
| 512 | + | state, | |
| 513 | + | sync: None, | |
| 514 | + | themes: themes(), | |
| 515 | + | intents: &intents, | |
| 516 | + | }; | |
| 517 | + | let closed = window(ctx, title, &mut runtime, &host, home, true); | |
| 518 | + | state.described.naming = runtime; | |
| 519 | + | let finished = intents | |
| 520 | + | .borrow() | |
| 521 | + | .iter() | |
| 522 | + | .any(|i| matches!(i, Intent::NamingDone)); | |
| 523 | + | apply(ctx, state, None, intents.into_inner()); | |
| 524 | + | // The window's own X is the fourth way out, beside Cancel, an empty submit | |
| 525 | + | // and a successful one. It raises no intent, so it is answered here with | |
| 526 | + | // what `Intent::NamingDone` would have done. | |
| 527 | + | if closed || finished { | |
| 528 | + | state.described.naming = None; | |
| 529 | + | state.vfs_modal.show_vfs_create = false; | |
| 530 | + | state.vfs_modal.show_dir_create = false; | |
| 531 | + | state.vfs_modal.vfs_rename_target = None; | |
| 532 | + | state.vfs_modal.dir_rename_target = None; | |
| 533 | + | state.vfs_modal.name_modal_error = None; | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | ||
| 492 | 537 | /// Do what a described screen asked the app to do to itself. | |
| 493 | 538 | /// | |
| 494 | 539 | /// **The frame boundary.** A route holds `&BrowserState` and cannot select a | |
| @@ -919,6 +964,15 @@ | |||
| 919 | 964 | // is the one place this port does that and `naming`'s header is | |
| 920 | 965 | // why: the store's refusal has to reach the field it was typed | |
| 921 | 966 | // into. What is left is the half a route cannot do. | |
| 967 | + | // Whichever of the four was up, and the error with it: the modal is | |
| 968 | + | // finished with, so a refusal it was showing is finished with too. | |
| 969 | + | Intent::NamingDone => { | |
| 970 | + | state.vfs_modal.show_vfs_create = false; | |
| 971 | + | state.vfs_modal.show_dir_create = false; | |
| 972 | + | state.vfs_modal.vfs_rename_target = None; | |
| 973 | + | state.vfs_modal.dir_rename_target = None; | |
| 974 | + | state.vfs_modal.name_modal_error = None; | |
| 975 | + | } | |
| 922 | 976 | Intent::VaultsChanged(say) => { | |
| 923 | 977 | state.refresh_vfs_list(); | |
| 924 | 978 | state.status = say; |
| @@ -545,6 +545,24 @@ | |||
| 545 | 545 | .dropping("Close window") | |
| 546 | 546 | } | |
| 547 | 547 | ||
| 548 | + | /// A field the description names and `makeover-immediate` draws unnamed. | |
| 549 | + | /// | |
| 550 | + | /// `makeover_immediate::field` draws `Field::label` as a `ui.label` above | |
| 551 | + | /// the control and never associates the two, so what reaches the widget tree | |
| 552 | + | /// is an unnamed box with some text near it. A screen reader announces a | |
| 553 | + | /// text field with no question, and this reader -- which drops a control | |
| 554 | + | /// with nothing to say -- sees no control at all. | |
| 555 | + | /// | |
| 556 | + | /// The same gap the shipped panels have, inherited. It is worse here only | |
| 557 | + | /// because the description **knows** the label: `Field::label` is right | |
| 558 | + | /// there, and `egui::Response::labelled_by` is the one call that would | |
| 559 | + | /// attach it. Filed against makeover-immediate; until it is fixed and | |
| 560 | + | /// published, every described field is an allowance at a call site. | |
| 561 | + | #[must_use] | |
| 562 | + | pub(super) fn unnamed_field(self, label: &str) -> Self { | |
| 563 | + | self.gaining(label) | |
| 564 | + | } | |
| 565 | + | ||
| 548 | 566 | /// Assert the two sides offer the same thing, panicking with a diff if not. | |
| 549 | 567 | pub(super) fn assert(&self, described: &Offering, shipped: &Offering) { | |
| 550 | 568 | let mut want: BTreeMap<Offer, isize> = BTreeMap::new(); | |
| @@ -769,3 +787,92 @@ | |||
| 769 | 787 | .in_a_window("Loose-files mode warning") | |
| 770 | 788 | .assert(&described, &drawn); | |
| 771 | 789 | } | |
| 790 | + | ||
| 791 | + | /// The four name modals: what each is called, and where it is served from. | |
| 792 | + | /// | |
| 793 | + | /// A table rather than four tests, because they are one screen four times and | |
| 794 | + | /// the flip's claim is exactly that. | |
| 795 | + | #[test] | |
| 796 | + | fn the_four_name_modals_serve_what_they_describe() { | |
| 797 | + | type Show = fn(&mut crate::state::BrowserState); | |
| 798 | + | ||
| 799 | + | let modals: [(&str, &str, Show); 4] = [ | |
| 800 | + | ("New Vault", "/vaults/new", |state| { | |
| 801 | + | state.vfs_modal.show_vfs_create = true; | |
| 802 | + | }), | |
| 803 | + | ("Rename Vault", "/vaults/{id}/rename", |state| { | |
| 804 | + | let vault = state.nav.vfs_list[0].clone(); | |
| 805 | + | state.vfs_modal.vfs_rename_target = Some((vault.id, vault.name)); | |
| 806 | + | }), | |
| 807 | + | ("New Folder", "/folders/new", |state| { | |
| 808 | + | state.vfs_modal.show_dir_create = true; | |
| 809 | + | }), | |
| 810 | + | ("Rename", "/folders/{id}/rename", |state| { | |
| 811 | + | let folder = state.nav.contents[0].node.clone(); | |
| 812 | + | state.vfs_modal.dir_rename_target = Some((folder.id, folder.name)); | |
| 813 | + | }), | |
| 814 | + | ]; | |
| 815 | + | ||
| 816 | + | // What each modal asks, and what the renderer does not attach to the box. | |
| 817 | + | // See `Parity::unnamed_field`. | |
| 818 | + | let asked = ["Vault name", "New name", "Folder name", "New name"]; | |
| 819 | + | ||
| 820 | + | for ((title, address, show), asked) in modals.into_iter().zip(asked) { | |
| 821 | + | let (mut state, _dir) = fixture(); | |
| 822 | + | // A folder to rename, which the sample-only fixture does not have. | |
| 823 | + | let vault = state.current_vfs_id().unwrap(); | |
| 824 | + | let parent = state.nav.current_dir; | |
| 825 | + | state | |
| 826 | + | .backend | |
| 827 | + | .create_directory(vault, parent, "drums") | |
| 828 | + | .unwrap(); | |
| 829 | + | state.refresh_contents(); | |
| 830 | + | show(&mut state); | |
| 831 | + | ||
| 832 | + | // What a prefilled unnamed box is announced as: its own contents. The | |
| 833 | + | // other half of `unnamed_field` -- with no label attached, a rename | |
| 834 | + | // modal's field reports the name it was seeded with. | |
| 835 | + | let prefilled = state | |
| 836 | + | .vfs_modal | |
| 837 | + | .vfs_rename_target | |
| 838 | + | .as_ref() | |
| 839 | + | .map(|(_, name)| name.clone()) | |
| 840 | + | .or_else(|| { | |
| 841 | + | state | |
| 842 | + | .vfs_modal | |
| 843 | + | .dir_rename_target | |
| 844 | + | .as_ref() | |
| 845 | + | .map(|(_, name)| name.clone()) | |
| 846 | + | }); | |
| 847 | + | ||
| 848 | + | let address = address.replace("{id}", &real_id(&state, address).to_string()); | |
| 849 | + | let described = described(&super::panel::described_screen(&state, &address)); | |
| 850 | + | let drawn = shipped(|ui| { | |
| 851 | + | super::panel::draw_naming(ui.ctx(), &mut state, title, &address); | |
| 852 | + | }); | |
| 853 | + | ||
| 854 | + | described.addresses_resolve(); | |
| 855 | + | let mut parity = Parity::strict().in_a_window(title).unnamed_field(asked); | |
| 856 | + | if let Some(prefilled) = &prefilled { | |
| 857 | + | parity = parity.dropping(prefilled); | |
| 858 | + | } | |
| 859 | + | parity.assert(&described, &drawn); | |
| 860 | + | } | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | /// The id the rename addresses need, read off the fixture. | |
| 864 | + | fn real_id(state: &crate::state::BrowserState, address: &str) -> i64 { | |
| 865 | + | if address.starts_with("/vaults/") { | |
| 866 | + | state.nav.vfs_list[0].id.as_i64() | |
| 867 | + | } else { | |
| 868 | + | state | |
| 869 | + | .nav | |
| 870 | + | .contents | |
| 871 | + | .iter() | |
| 872 | + | .map(|node| &node.node) | |
| 873 | + | .find(|node| node.sample_hash.is_none()) | |
| 874 | + | .expect("the fixture makes a folder") | |
| 875 | + | .id | |
| 876 | + | .as_i64() | |
| 877 | + | } | |
| 878 | + | } |
| @@ -5006,6 +5006,8 @@ | |||
| 5006 | 5006 | struct Unnamed; | |
| 5007 | 5007 | ||
| 5008 | 5008 | impl Naming for Unnamed { | |
| 5009 | + | fn done(&self) {} | |
| 5010 | + | ||
| 5009 | 5011 | fn vault(&self, _id: i64) -> Option<String> { | |
| 5010 | 5012 | None | |
| 5011 | 5013 | } | |
| @@ -5101,6 +5103,12 @@ | |||
| 5101 | 5103 | folders: Vec<(i64, String)>, | |
| 5102 | 5104 | refusing: Option<String>, | |
| 5103 | 5105 | asked: RefCell<Vec<String>>, | |
| 5106 | + | /// Whether the modal was told it is finished with. | |
| 5107 | + | /// | |
| 5108 | + | /// Its own field rather than a row in `asked`, because it is not a naming | |
| 5109 | + | /// operation: `asked` answers "what did this screen do to the tree", and | |
| 5110 | + | /// telling the host to put a window away does nothing to the tree. | |
| 5111 | + | finished: std::cell::Cell<bool>, | |
| 5104 | 5112 | } | |
| 5105 | 5113 | ||
| 5106 | 5114 | impl FakeNaming { | |
| @@ -5123,6 +5131,10 @@ | |||
| 5123 | 5131 | self.asked.borrow().clone() | |
| 5124 | 5132 | } | |
| 5125 | 5133 | ||
| 5134 | + | fn finished(&self) -> bool { | |
| 5135 | + | self.finished.get() | |
| 5136 | + | } | |
| 5137 | + | ||
| 5126 | 5138 | fn did(&self, what: String) -> Result<String, String> { | |
| 5127 | 5139 | self.asked.borrow_mut().push(what.clone()); | |
| 5128 | 5140 | match &self.refusing { | |
| @@ -5133,6 +5145,10 @@ | |||
| 5133 | 5145 | } | |
| 5134 | 5146 | ||
| 5135 | 5147 | impl Naming for FakeNaming { | |
| 5148 | + | fn done(&self) { | |
| 5149 | + | self.finished.set(true); | |
| 5150 | + | } | |
| 5151 | + | ||
| 5136 | 5152 | fn vault(&self, id: i64) -> Option<String> { | |
| 5137 | 5153 | self.vaults | |
| 5138 | 5154 | .iter() | |
| @@ -5317,6 +5333,9 @@ | |||
| 5317 | 5333 | ||
| 5318 | 5334 | assert!(matches!(response.outcome, Outcome::Goto(_)), "{response:?}"); | |
| 5319 | 5335 | assert!(namer.asked().is_empty()); | |
| 5336 | + | // Leaving the address is not leaving the screen: the host's own flag is | |
| 5337 | + | // what keeps a name modal up, so every exit says so. See `naming`'s `DONE`. | |
| 5338 | + | assert!(namer.finished()); | |
| 5320 | 5339 | } | |
| 5321 | 5340 | ||
| 5322 | 5341 | #[test] |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Overlay windows: help dialog, delete confirmation, and bulk operation modals. | |
| 2 | 2 | ||
| 3 | 3 | use super::theme; | |
| 4 | - | use super::widgets::{self, ConfirmOutcome, ConfirmSpec, NameModalOutcome}; | |
| 4 | + | use super::widgets::{self, ConfirmOutcome, ConfirmSpec}; | |
| 5 | 5 | use egui; | |
| 6 | 6 | ||
| 7 | 7 | use crate::state::{BrowserState, BulkModal, ConfirmAction}; | |
| @@ -845,196 +845,6 @@ | |||
| 845 | 845 | } | |
| 846 | 846 | } | |
| 847 | 847 | ||
| 848 | - | /// Shared outcome handling for the four name modals (create/rename × vault/folder). | |
| 849 | - | /// | |
| 850 | - | /// Each modal draws the same `name_modal` widget and then treats its outcome the | |
| 851 | - | /// same way: an empty or cancelled submit closes the modal; a non-empty submit | |
| 852 | - | /// runs the create/rename op, showing its status line on success or surfacing the | |
| 853 | - | /// error inline (C-3) while leaving the modal open with the typed name intact. | |
| 854 | - | /// Only the static labels, the name buffer, `close`, and `commit` differ per modal. | |
| 855 | - | /// | |
| 856 | - | /// `close` resets the modal (hides it, or clears its rename target); `commit` | |
| 857 | - | /// performs the op and returns the status line to show, or an error string to | |
| 858 | - | /// surface inline. | |
| 859 | - | fn handle_name_modal_outcome( | |
| 860 | - | outcome: NameModalOutcome, | |
| 861 | - | state: &mut BrowserState, | |
| 862 | - | close: impl FnOnce(&mut BrowserState), | |
| 863 | - | commit: impl FnOnce(&mut BrowserState, String) -> Result<String, String>, | |
| 864 | - | ) { | |
| 865 | - | match outcome { | |
| 866 | - | NameModalOutcome::Submitted(name) => { | |
| 867 | - | if name.is_empty() { | |
| 868 | - | // Empty submit = no-op cancel. Close without erroring. | |
| 869 | - | close(state); | |
| 870 | - | state.vfs_modal.name_modal_error = None; | |
| 871 | - | } else { | |
| 872 | - | match commit(state, name) { | |
| 873 | - | Ok(status) => { | |
| 874 | - | state.status = status; | |
| 875 | - | close(state); | |
| 876 | - | state.vfs_modal.name_modal_error = None; | |
| 877 | - | } | |
| 878 | - | // C-3: keep the modal open; surface the error inline so the | |
| 879 | - | // user can edit and retry without re-typing the name. | |
| 880 | - | Err(msg) => state.vfs_modal.name_modal_error = Some(msg), | |
| 881 | - | } | |
| 882 | - | } | |
| 883 | - | } | |
| 884 | - | NameModalOutcome::Cancelled => { | |
| 885 | - | close(state); | |
| 886 | - | state.vfs_modal.name_modal_error = None; | |
| 887 | - | } | |
| 888 | - | NameModalOutcome::None => {} | |
| 889 | - | } | |
| 890 | - | } | |
| 891 | - | ||
| 892 | - | /// Draw the "New Vault" modal: text input for vault name. | |
| 893 | - | pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) { | |
| 894 | - | // What a vault is, and how to nest one, are facts about the modal rather | |
| 895 | - | // than about the name being typed, so they stay a lead-in above the field. | |
| 896 | - | 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."; | |
| 897 | - | // C-3: clone the error into a local so the &mut input borrow doesn't | |
| 898 | - | // conflict with the immutable error borrow into name_modal. | |
| 899 | - | let error_owned = state.vfs_modal.name_modal_error.clone(); | |
| 900 | - | let outcome = widgets::name_modal( | |
| 901 | - | ctx, | |
| 902 | - | &widgets::NameModalSpec { | |
| 903 | - | title: "New Vault", | |
| 904 | - | lead_in: Some(lead_in), | |
| 905 | - | label: "Vault name", | |
| 906 | - | hint: None, | |
| 907 | - | placeholder: None, | |
| 908 | - | submit_label: "Create", | |
| 909 | - | error: error_owned.as_deref(), | |
| 910 | - | }, | |
| 911 | - | &mut state.vfs_modal.vfs_create_input, | |
| 912 | - | ); | |
| 913 | - | handle_name_modal_outcome( | |
| 914 | - | outcome, | |
| 915 | - | state, | |
| 916 | - | |s| s.vfs_modal.show_vfs_create = false, | |
| 917 | - | |s, name| { | |
| 918 | - | s.backend.create_vfs(&name).map_err(|e| format!("{e}"))?; | |
| 919 | - | s.refresh_vfs_list(); | |
| 920 | - | Ok(format!("Created vault: {name}")) | |
| 921 | - | }, | |
| 922 | - | ); | |
| 923 | - | } | |
| 924 | - | ||
| 925 | - | /// Draw the "Rename Vault" modal: text input pre-filled with current name. | |
| 926 | - | pub fn draw_vfs_rename_modal(ctx: &egui::Context, state: &mut BrowserState) { | |
| 927 | - | let error_owned = state.vfs_modal.name_modal_error.clone(); | |
| 928 | - | let (outcome, vfs_id) = if let Some((id, ref mut name_buf)) = state.vfs_modal.vfs_rename_target | |
| 929 | - | { | |
| 930 | - | ( | |
| 931 | - | widgets::name_modal( | |
| 932 | - | ctx, | |
| 933 | - | &widgets::NameModalSpec { | |
| 934 | - | title: "Rename Vault", | |
| 935 | - | lead_in: None, | |
| 936 | - | label: "New name", | |
| 937 | - | // About the answer, so it is the field's hint. | |
| 938 | - | hint: Some("Vault names can contain spaces."), | |
| 939 | - | placeholder: None, | |
| 940 | - | submit_label: "Save", | |
| 941 | - | error: error_owned.as_deref(), | |
| 942 | - | }, | |
| 943 | - | name_buf, | |
| 944 | - | ), | |
| 945 | - | id, | |
| 946 | - | ) | |
| 947 | - | } else { | |
| 948 | - | return; | |
| 949 | - | }; | |
| 950 | - | handle_name_modal_outcome( | |
| 951 | - | outcome, | |
| 952 | - | state, | |
| 953 | - | |s| s.vfs_modal.vfs_rename_target = None, | |
| 954 | - | move |s, new_name| { | |
| 955 | - | s.backend | |
| 956 | - | .rename_vfs(vfs_id, &new_name) | |
| 957 | - | .map_err(|e| format!("{e}"))?; | |
| 958 | - | s.refresh_vfs_list(); | |
| 959 | - | Ok(format!("Renamed vault to: {new_name}")) | |
| 960 | - | }, | |
| 961 | - | ); | |
| 962 | - | } | |
| 963 | - | ||
| 964 | - | /// Draw the "New Folder" modal: text input for folder name. | |
| 965 | - | pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) { | |
| 966 | - | let error_owned = state.vfs_modal.name_modal_error.clone(); | |
| 967 | - | let outcome = widgets::name_modal( | |
| 968 | - | ctx, | |
| 969 | - | &widgets::NameModalSpec { | |
| 970 | - | title: "New Folder", | |
| 971 | - | lead_in: None, | |
| 972 | - | label: "Folder name", | |
| 973 | - | // A constraint on the answer, so it is the field's hint. | |
| 974 | - | hint: Some("Folder names cannot contain /"), | |
| 975 | - | placeholder: None, | |
| 976 | - | submit_label: "Create", | |
| 977 | - | error: error_owned.as_deref(), | |
| 978 | - | }, | |
| 979 | - | &mut state.vfs_modal.dir_create_input, | |
| 980 | - | ); | |
| 981 | - | let vfs_id = state.current_vfs_id(); | |
| 982 | - | let current_dir = state.nav.current_dir; | |
| 983 | - | handle_name_modal_outcome( | |
| 984 | - | outcome, | |
| 985 | - | state, | |
| 986 | - | |s| s.vfs_modal.show_dir_create = false, | |
| 987 | - | move |s, name| { | |
| 988 | - | // The New Folder modal is only reachable inside a vault, so this is a | |
| 989 | - | // defensive guard rather than a real path. | |
| 990 | - | let vfs_id = vfs_id.ok_or_else(|| "No vault selected".to_string())?; | |
| 991 | - | s.backend | |
| 992 | - | .create_directory(vfs_id, current_dir, &name) | |
| 993 | - | .map_err(|e| format!("{e}"))?; | |
| 994 | - | s.refresh_contents(); | |
| 995 | - | Ok(format!("Created folder: {name}")) | |
| 996 | - | }, | |
| 997 | - | ); | |
| 998 | - | } | |
| 999 | - | ||
| 1000 | - | /// Draw the "Rename Folder" modal: text input pre-filled with current name. | |
| 1001 | - | pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) { | |
| 1002 | - | let error_owned = state.vfs_modal.name_modal_error.clone(); | |
| 1003 | - | let (outcome, node_id) = if let Some((id, ref mut name_buf)) = state.vfs_modal.dir_rename_target | |
| 1004 | - | { | |
| 1005 | - | ( | |
| 1006 | - | widgets::name_modal( | |
| 1007 | - | ctx, | |
| 1008 | - | &widgets::NameModalSpec { | |
| 1009 | - | title: "Rename", | |
| 1010 | - | lead_in: None, | |
| 1011 | - | label: "New name", | |
| 1012 | - | hint: None, | |
| 1013 | - | placeholder: None, | |
| 1014 | - | submit_label: "Save", | |
| 1015 | - | error: error_owned.as_deref(), | |
| 1016 | - | }, | |
| 1017 | - | name_buf, | |
| 1018 | - | ), | |
| 1019 | - | id, | |
| 1020 | - | ) | |
| 1021 | - | } else { | |
| 1022 | - | return; | |
| 1023 | - | }; | |
| 1024 | - | handle_name_modal_outcome( | |
| 1025 | - | outcome, | |
| 1026 | - | state, | |
| 1027 | - | |s| s.vfs_modal.dir_rename_target = None, | |
| 1028 | - | move |s, new_name| { | |
| 1029 | - | s.backend | |
| 1030 | - | .rename_node(node_id, &new_name) | |
| 1031 | - | .map_err(|e| format!("{e}"))?; | |
| 1032 | - | s.refresh_contents(); | |
| 1033 | - | Ok(format!("Renamed to: {new_name}")) | |
| 1034 | - | }, | |
| 1035 | - | ); | |
| 1036 | - | } | |
| 1037 | - | ||
| 1038 | 848 | #[cfg(test)] | |
| 1039 | 849 | mod tests { | |
| 1040 | 850 | use super::*; |