Skip to main content

max / audiofiles

Flip the three bulk modals to the described screens Tag, move and rename serve from `quasi::bulk`, and `draw_bulk_modal` with its three drawing functions is gone from `ui/overlays.rs`. Which one is showing is still the app's own `bulk_modal`, so the address is built at the call site, and `POST /bulk/done` is `naming`'s dismiss route in a second consumer. The rename modal's nine-token palette is described rather than lost. It was nine inert badges with a comment naming the gap they stood in for -- `quasi:vocabulary:text-into-field`, filed by the export port for the same control. Max ruled it, `Act::fills` landed in quasi 0.55.0, and the tokens are acts now: `Action::local` says no request goes out, and the renderer appends exactly as `pattern_input.push_str(token)` did, so `{name}_{bpm}` is still two presses. Two changes the flip makes, both named in the test. Bulk Move drops the pick-a-dropdown-then-press-Move-Items pair for a table of destinations where each row submits itself, so choosing is the act. And Bulk Rename's submit is live on the frame it appears: the shipped button was dead until a keystroke ran `update_rename_previews`, even though the pattern it opened with renames everything chosen.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 20:47 UTC
Signed with PGP, not checked
Commit: fe7d76f61ccf9aaae7b278e0f39a2e26e7cc7959
Parent: 4ba8a87
7 files changed, +162 insertions, -404 deletions
@@ -134,8 +134,19 @@
134 134 if state.overlay.pending_confirm.is_some() {
135 135 overlays::draw_confirm_dialog(ctx, state);
136 136 }
137 - if state.bulk_modal.is_some() {
138 - overlays::draw_bulk_modal(ctx, state);
137 + // The three bulk modals, all of them `quasi::bulk`. Which one is showing is
138 + // still the app's own `bulk_modal`, so the address is built here.
139 + match &state.bulk_modal {
140 + Some(crate::state::BulkModal::Tag { .. }) => {
141 + crate::quasi::panel::draw_bulk(ctx, state, "Bulk Tag", "/bulk/tag");
142 + }
143 + Some(crate::state::BulkModal::Move { .. }) => {
144 + crate::quasi::panel::draw_bulk(ctx, state, "Bulk Move", "/bulk/move");
145 + }
146 + Some(crate::state::BulkModal::Rename { .. }) => {
147 + crate::quasi::panel::draw_bulk(ctx, state, "Bulk Rename", "/bulk/rename");
148 + }
149 + None => {}
139 150 }
140 151 if state.overlay.show_help {
141 152 overlays::draw_help_overlay(ctx, state);
@@ -142,6 +142,7 @@
142 142 .get("/bulk/rename", rename_screen)
143 143 .post("/bulk/rename/preview", preview)
144 144 .post("/bulk/rename", rename)
145 + .post(DONE, done)
145 146 }
146 147
147 148 /// `GET /bulk/tag`
@@ -174,6 +175,7 @@
174 175 return Err(RouteError::not_found(UNKNOWN));
175 176 }
176 177 state.bulk.tag(typed, adding);
178 + state.bulk.done();
177 179 Ok(Response::from(leaving()).toast(
178 180 Tone::Success,
179 181 format!(
@@ -271,6 +273,7 @@
271 273 };
272 274 let count = state.bulk.chosen().names.len();
273 275 state.bulk.move_to(folder);
276 + state.bulk.done();
274 277 Ok(Response::from(leaving()).toast(Tone::Success, format!("Moving {count} items.")))
275 278 }
276 279
@@ -351,10 +354,24 @@
351 354 return Err(RouteError::not_found("that pattern renames nothing"));
352 355 }
353 356 state.bulk.rename(pattern);
357 + state.bulk.done();
354 358 Ok(Response::from(leaving())
355 359 .toast(Tone::Success, format!("Renaming {} items.", previews.len())))
356 360 }
357 361
362 + /// The route that says a bulk modal is finished with, whichever of the three.
363 + ///
364 + /// `naming`'s `DONE` in a second consumer. See its header: the host's own flag
365 + /// is what keeps one of these up, so every exit has to say so as well as
366 + /// navigate.
367 + const DONE: &str = "/bulk/done";
368 +
369 + /// `POST /bulk/done`
370 + fn done(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
371 + state.bulk.done();
372 + Ok(Response::from(leaving()))
373 + }
374 +
358 375 /// The region the preview lands in.
359 376 const PREVIEW: &str = "bulk-rename-preview";
360 377
@@ -362,13 +379,22 @@
362 379 fn renaming(state: &Panels<'_>, pattern: &str) -> Screen {
363 380 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Rename pattern"));
364 381
365 - // The tokens, as chips that say what they are. The shipped modal appends
366 - // the token to the buffer when one is pressed, which is
367 - // `quasi:vocabulary:text-into-field` -- the finding the export port filed
368 - // for the same control, with the same workaround: name them and let the
369 - // reader type.
382 + // The tokens, as controls that put themselves in the box.
383 + //
384 + // Badges until 2026-08-22, with a comment naming the gap they were standing
385 + // in for: `quasi:vocabulary:text-into-field`, filed by the export port for
386 + // the same control. Max ruled it (`f35aafee`) and `Act::fills` landed in
387 + // quasi 0.55.0, so the workaround is over and the flip does not have to
388 + // lose the shipped palette. `Action::local` is the "no request goes out"
389 + // half: pressing one of these writes into the box and calls nothing.
390 + //
391 + // The renderer appends rather than replaces, which is what the shipped
392 + // modal's `pattern_input.push_str(token)` does, so `{name}_{bpm}` is still
393 + // built by pressing two of them.
370 394 for token in TOKENS {
371 - body = body.with(Node::Token(Tag::badge(token)));
395 + body = body.with(Node::Act(
396 + Act::new(token, Action::local()).filling(PATTERN, token),
397 + ));
372 398 }
373 399
374 400 body = body.with(Node::Form {
@@ -394,7 +420,7 @@
394 420 /// instead.
395 421 fn closing(body: Slot) -> Screen {
396 422 Screen::sidebar_content("Bulk")
397 - .with(body.with(Node::Act(Act::new("Cancel", Action::get(BACK)).key("esc"))))
423 + .with(body.with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc"))))
398 424 }
399 425
400 426 /// What the pattern would do, old name beside new.
@@ -750,6 +750,8 @@
750 750 BulkRename(String),
751 751 /// A name modal is finished with, whichever of the four it was.
752 752 NamingDone,
753 + /// A bulk modal is finished with, whichever of the three it was.
754 + BulkDone,
753 755 /// Re-read the vault list, and say this about why.
754 756 VaultsChanged(String),
755 757 /// Re-read the current folder, and say this about why.
@@ -1913,6 +1915,13 @@
1913 1915 /// naming what a pattern expands to is the app's, and a description that
1914 1916 /// reimplemented it would be a second answer free to disagree with the first.
1915 1917 pub trait Bulk {
1918 + /// The modal is finished with: put it away.
1919 + ///
1920 + /// `naming`'s `done` in a second consumer, and for the same reason: what
1921 + /// keeps one of these on screen is the host's own `bulk_modal`, so leaving
1922 + /// the address is not leaving the screen.
1923 + fn done(&self);
1924 +
1916 1925 /// What is chosen.
1917 1926 fn chosen(&self) -> Chosen;
1918 1927
@@ -1947,6 +1956,10 @@
1947 1956 }
1948 1957
1949 1958 impl Bulk for FromBulk<'_> {
1959 + fn done(&self) {
1960 + self.intents.borrow_mut().push(Intent::BulkDone);
1961 + }
1962 +
1950 1963 fn chosen(&self) -> Chosen {
1951 1964 let nodes = self.state.selected_nodes();
1952 1965 Chosen {
@@ -61,6 +61,7 @@
61 61 filters: Option<Runtime>,
62 62 integrity: Option<Runtime>,
63 63 naming: Option<Runtime>,
64 + bulk: Option<Runtime>,
64 65 /// Whether the described main window is open.
65 66 pub show_shell: bool,
66 67 /// Whether the described file list is open.
@@ -485,6 +486,38 @@
485 486 }
486 487 }
487 488
489 + /// Draw one of the three bulk modals, and act on whatever was pressed.
490 + ///
491 + /// `draw_naming`'s shape, for the same reason: three screens differing in what
492 + /// they ask, and the app's own `bulk_modal` is what knows which is showing.
493 + ///
494 + /// Refreshed unconditionally. The rename preview answers as a fragment while
495 + /// the pattern is typed, and a screen that is not re-asked shows the previews
496 + /// from before the last keystroke.
497 + pub fn draw_bulk(ctx: &egui::Context, state: &mut BrowserState, title: &str, home: &str) {
498 + let intents = RefCell::new(Vec::new());
499 + let mut runtime = state.described.bulk.take();
500 + let host = Host {
501 + state,
502 + sync: None,
503 + themes: themes(),
504 + intents: &intents,
505 + };
506 + let closed = window(ctx, title, &mut runtime, &host, home, true);
507 + state.described.bulk = runtime;
508 + let finished = intents
509 + .borrow()
510 + .iter()
511 + .any(|intent| matches!(intent, Intent::BulkDone));
512 + apply(ctx, state, None, intents.into_inner());
513 + // The window's own X raises no intent, so it is answered here with what
514 + // `Intent::BulkDone` would have done.
515 + if closed || finished {
516 + state.described.bulk = None;
517 + state.close_bulk_modal();
518 + }
519 + }
520 +
488 521 /// Do what a described screen asked the app to do to itself.
489 522 ///
490 523 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -924,6 +957,8 @@
924 957 state.vfs_modal.dir_rename_target = None;
925 958 state.vfs_modal.name_modal_error = None;
926 959 }
960 + // Whichever of the three was up.
961 + Intent::BulkDone => state.close_bulk_modal(),
927 962 Intent::VaultsChanged(say) => {
928 963 state.refresh_vfs_list();
929 964 state.status = say;
@@ -1045,3 +1045,39 @@
1045 1045 // there is no frame to discount.
1046 1046 Parity::strict().assert(&described, &drawn);
1047 1047 }
1048 +
1049 + /// Two samples chosen, which is what every bulk modal needs.
1050 + fn with_two_chosen(state: &mut crate::state::BrowserState) {
1051 + state.nav.selection.select_all(state.nav.contents.len());
1052 + }
1053 +
1054 + #[test]
1055 + fn the_three_bulk_modals_serve_what_they_describe() {
1056 + type Open = fn(&mut crate::state::BrowserState);
1057 +
1058 + let modals: [(&str, &str, Open); 3] = [
1059 + ("Bulk Tag", "/bulk/tag", |state| state.open_bulk_tag_modal()),
1060 + ("Bulk Move", "/bulk/move", |state| {
1061 + state.open_bulk_move_modal();
1062 + }),
1063 + ("Bulk Rename", "/bulk/rename", |state| {
1064 + state.open_bulk_rename_modal();
1065 + }),
1066 + ];
1067 +
1068 + for (title, address, open) in modals {
1069 + let (mut state, _dir) = fixture();
1070 + with_two_chosen(&mut state);
1071 + open(&mut state);
1072 +
1073 + let described = described(&super::panel::described_screen(&state, address));
1074 + let drawn = shipped(|ui| {
1075 + super::panel::draw_bulk(ui.ctx(), &mut state, title, address);
1076 + });
1077 +
1078 + described.addresses_resolve();
1079 + Parity::strict()
1080 + .in_a_window(title)
1081 + .assert(&described, &drawn);
1082 + }
1083 + }
@@ -2977,6 +2977,8 @@
2977 2977 struct Unchosen;
2978 2978
2979 2979 impl Bulk for Unchosen {
2980 + fn done(&self) {}
2981 +
2980 2982 fn chosen(&self) -> Chosen {
2981 2983 Chosen {
2982 2984 names: Vec::new(),
@@ -3007,6 +3009,12 @@
3007 3009 tags: Vec<String>,
3008 3010 folders: Vec<Folder>,
3009 3011 asked: RefCell<Vec<String>>,
3012 + /// Whether the modal was told it is finished with.
3013 + ///
3014 + /// Its own field rather than a row in `asked`, for `FakeNaming::finished`'s
3015 + /// reason: `asked` answers what this screen did to the library, and putting
3016 + /// a window away does nothing to it.
3017 + finished: std::cell::Cell<bool>,
3010 3018 }
3011 3019
3012 3020 impl FakeBulk {
@@ -3028,15 +3036,24 @@
3028 3036 },
3029 3037 ],
3030 3038 asked: RefCell::new(Vec::new()),
3039 + finished: std::cell::Cell::new(false),
3031 3040 }
3032 3041 }
3033 3042
3043 + fn finished(&self) -> bool {
3044 + self.finished.get()
3045 + }
3046 +
3034 3047 fn asked(&self) -> Vec<String> {
3035 3048 self.asked.borrow().clone()
3036 3049 }
3037 3050 }
3038 3051
3039 3052 impl Bulk for FakeBulk {
3053 + fn done(&self) {
3054 + self.finished.set(true);
3055 + }
3056 +
3040 3057 fn chosen(&self) -> Chosen {
3041 3058 self.chosen.clone()
3042 3059 }
@@ -3414,7 +3431,11 @@
3414 3431 fn a_finished_modal_goes_somewhere_because_that_is_all_it_can_say() {
3415 3432 // FINDING 1, asserted rather than only written down: there is no action
3416 3433 // meaning "close what is on top", so every way out of a described modal is
3417 - // a navigation.
3434 + // a navigation. Since the flip (2026-08-22) it is a navigation with a stop
3435 + // on the way -- `/bulk/done`, which tells the host to put the window away,
3436 + // because the host's own `bulk_modal` is what keeps it up and leaving the
3437 + // address is not leaving the screen. The finding is unchanged: the
3438 + // vocabulary still cannot say "this overlay is finished".
3418 3439 let bulk = FakeBulk::of(&["kick.wav"], 1);
3419 3440 let done = bulking(
3420 3441 &bulk,
@@ -3434,7 +3455,15 @@
3434 3455 })
3435 3456 .expect("the modal offers a way out");
3436 3457 assert_eq!(cancel.key.as_deref(), Some("esc"));
3437 - assert_eq!(cancel.action.destination.route(), Some("/detail"));
3458 + assert_eq!(cancel.action.destination.route(), Some("/bulk/done"));
3459 +
3460 + // And that route says both halves: the host is told, and the answer leaves.
3461 + let left = bulking(&bulk, Request::post("/bulk/done")).unwrap();
3462 + assert!(bulk.finished());
3463 + match left.outcome {
3464 + Outcome::Goto(action) => assert_eq!(action.destination.route(), Some("/detail")),
3465 + other => panic!("expected a navigation, got {other:?}"),
3466 + }
3438 3467 }
3439 3468
3440 3469 #[test]
@@ -4,7 +4,7 @@
4 4 use super::widgets::{self, ConfirmOutcome, ConfirmSpec};
5 5 use egui;
6 6
7 - use crate::state::{BrowserState, BulkModal, ConfirmAction};
7 + use crate::state::{BrowserState, ConfirmAction};
8 8
9 9 // p-5: render platform-correct modifier name in shortcut copy. Sticking with
10 10 // the text "Cmd" rather than the glyph keeps us inside the unicode allowlist.
@@ -453,398 +453,6 @@
453 453 }
454 454 }
455 455
456 - /// Draw the active bulk modal (tag, move, or rename).
457 - pub fn draw_bulk_modal(ctx: &egui::Context, state: &mut BrowserState) {
458 - let modal_kind = match &state.bulk_modal {
459 - Some(BulkModal::Tag { .. }) => "tag",
460 - Some(BulkModal::Move { .. }) => "move",
461 - Some(BulkModal::Rename { .. }) => "rename",
462 - None => return,
463 - };
464 -
465 - match modal_kind {
466 - "tag" => draw_bulk_tag_modal(ctx, state),
467 - "move" => draw_bulk_move_modal(ctx, state),
468 - "rename" => draw_bulk_rename_modal(ctx, state),
469 - _ => {}
470 - }
471 - }
472 -
473 - /// Draw the bulk tag modal: add or remove a tag from all selected samples.
474 - ///
475 - /// Uses a two-flag pattern (`should_close`, `should_execute`) because egui closures
476 - /// borrow `state`, mutations must happen after the window closure returns.
477 - fn draw_bulk_tag_modal(ctx: &egui::Context, state: &mut BrowserState) {
478 - let mut should_close = false;
479 - let mut should_execute = false;
480 -
481 - // M-4: clone the tag list once up front so the autocomplete row can read
482 - // it without conflicting with the &mut borrow of state.bulk_modal inside
483 - // the closure.
484 - let all_tags: Vec<String> = state.all_tags.iter().cloned().collect();
485 -
486 - widgets::modal_window(ctx, "Bulk Tag", false, Some(350.0), |ui| {
487 - if let Some(BulkModal::Tag {
488 - ref mut tag_input,
489 - ref mut adding,
490 - ref names,
491 - ..
492 - }) = state.bulk_modal
493 - {
494 - ui.heading(format!("Tag {} samples", names.len()));
495 - ui.add_space(theme::space::bound());
496 -
497 - ui.horizontal(|ui| {
498 - ui.selectable_value(adding, true, "Add tag");
499 - ui.selectable_value(adding, false, "Remove tag");
500 - });
501 - ui.add_space(theme::space::bound());
502 -
503 - let resp = widgets::text_field(
504 - ui,
505 - egui::TextEdit::singleline(tag_input)
506 - .hint_text("e.g. genre.electronic")
507 - .desired_width(300.0),
508 - );
509 - if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
510 - should_execute = true;
511 - }
512 -
513 - ui.add_space(theme::space::bound());
514 -
515 - // M-4: autocomplete chips. Substring match, case-insensitive,
516 - // capped at 12. Click replaces tag_input (single-tag modal).
517 - let trimmed = tag_input.trim().to_lowercase();
518 - if !trimmed.is_empty() {
519 - let suggestions: Vec<&String> = all_tags
520 - .iter()
521 - .filter(|t| t.to_lowercase().contains(&trimmed) && t.as_str() != tag_input)
522 - .take(12)
523 - .collect();
524 - if !suggestions.is_empty() {
525 - ui.horizontal_wrapped(|ui| {
526 - for tag in suggestions {
527 - if widgets::selectable_tag(ui, false, tag.as_str()).clicked() {
528 - tag_input.clone_from(tag);
529 - }
530 - }
531 - });
532 - ui.add_space(theme::space::bound());
533 - }
534 - }
535 -
536 - // M-5: when removing, surface whether the tag is even known. We
537 - // intentionally avoid the O(samples) per-frame check via
538 - // get_sample_tags, for 1000-sample selections that would
539 - // re-scan every frame. Cheaper proxy: if the tag isn't in
540 - // all_tags at all, count is provably 0 and we can disable
541 - // Apply. Otherwise we hedge with copy that names the
542 - // uncertainty.
543 - let mut apply_enabled = true;
544 - let mut apply_hover_disabled: Option<&'static str> = None;
545 - if !*adding && !tag_input.trim().is_empty() {
546 - let typed = tag_input.trim();
547 - let known = all_tags.iter().any(|t| t == typed);
548 - if known {
549 - ui.label(
550 - egui::RichText::new(
551 - "Will remove from selected samples that have this tag.",
552 - )
553 - .small()
554 - .color(theme::content_secondary()),
555 - );
556 - } else {
557 - ui.label(
558 - egui::RichText::new("None of the selected samples have this tag.")
559 - .small()
560 - .color(theme::warning()),
561 - );
562 - apply_enabled = false;
563 - apply_hover_disabled = Some("None of the selected samples have this tag.");
564 - }
565 - ui.add_space(theme::space::bound());
566 - }
567 -
568 - egui::ScrollArea::vertical()
569 - .max_height(120.0)
570 - .show(ui, |ui| {
571 - for name in names {
572 - ui.label(
573 - egui::RichText::new(name)
574 - .small()
575 - .color(theme::content_secondary()),
576 - );
577 - }
578 - });
579 -
580 - ui.add_space(theme::space::peer());
581 - // p-4: Cmd+Enter (Ctrl+Enter on non-mac) confirms primary action,
582 - // gated by the same apply_enabled check the button uses (m-5).
583 - if apply_enabled && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))
584 - {
585 - should_execute = true;
586 - }
587 - // M-5: confirm_action_row doesn't accept a disabled flag, so we
588 - // hand-render the row to add `on_disabled_hover_text` honestly.
589 - ui.horizontal(|ui| {
590 - if ui.button("Cancel").clicked() {
591 - should_close = true;
592 - }
593 - let btn = ui.add_enabled(apply_enabled, egui::Button::new("Apply"));
594 - let btn = if let Some(hint) = apply_hover_disabled {
595 - btn.on_disabled_hover_text(hint)
596 - } else {
597 - btn
598 - };
599 - if btn.clicked() {
600 - should_execute = true;
601 - }
602 - });
603 - }
604 - });
605 -
606 - if should_execute {
607 - state.execute_bulk_tag();
608 - } else if should_close {
609 - state.close_bulk_modal();
610 - }
611 - }
612 -
613 - /// Draw the bulk move modal: pick a destination directory for all selected items.
614 - ///
615 - /// Presents a scrollable list of directories in the current VFS, plus a root option.
616 - fn draw_bulk_move_modal(ctx: &egui::Context, state: &mut BrowserState) {
617 - let mut should_close = false;
618 - let mut should_execute = false;
619 -
620 - widgets::modal_window(ctx, "Move Items", false, Some(400.0), |ui| {
621 - if let Some(BulkModal::Move {
622 - ref names,
623 - ref directories,
624 - ref mut selected_idx,
625 - ..
626 - }) = state.bulk_modal
627 - {
628 - ui.heading(format!("Move {} items", names.len()));
629 - ui.add_space(theme::space::bound());
630 - ui.label("Select destination folder:");
631 - ui.add_space(theme::space::bound());
632 -
633 - // M-6: substring filter (case-insensitive) over the directory paths.
634 - widgets::text_field(
635 - ui,
636 - egui::TextEdit::singleline(&mut state.import_wf.bulk_move_filter)
637 - .hint_text("Filter folders...")
638 - .desired_width(360.0),
639 - );
640 - ui.add_space(theme::space::bound());
641 -
642 - let filter = state.import_wf.bulk_move_filter.trim().to_lowercase();
643 -
644 - egui::ScrollArea::vertical()
645 - .max_height(200.0)
646 - .show(ui, |ui| {
647 - // Show the root only when the filter is empty, the
648 - // string "/" is too short to be meaningful in a filter.
649 - if filter.is_empty() {
650 - let is_root_selected = selected_idx.is_none();
651 - if ui.selectable_label(is_root_selected, "/").clicked() {
652 - *selected_idx = None;
653 - }
654 - }
655 -
656 - for (i, (_, path)) in directories.iter().enumerate() {
657 - if !filter.is_empty() && !path.to_lowercase().contains(&filter) {
658 - continue;
659 - }
660 - let is_selected = *selected_idx == Some(i);
661 - if ui.selectable_label(is_selected, path).clicked() {
662 - *selected_idx = Some(i);
663 - }
664 - }
665 - });
666 -
667 - ui.add_space(theme::space::peer());
668 - // p-4: Cmd+Enter confirms the primary action.
669 - if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) {
670 - should_execute = true;
671 - }
672 - match widgets::confirm_action_row(ui, "Move", true, false) {
673 - ConfirmOutcome::Confirmed => should_execute = true,
674 - ConfirmOutcome::Cancelled => should_close = true,
675 - ConfirmOutcome::None => {}
676 - }
677 - }
678 - });
679 -
680 - if should_execute {
681 - // M-6: reset the filter when the modal closes (execute path).
682 - state.import_wf.bulk_move_filter.clear();
683 - state.execute_bulk_move();
684 - } else if should_close {
685 - // M-6: reset the filter when the modal closes (cancel path).
686 - state.import_wf.bulk_move_filter.clear();
687 - state.close_bulk_modal();
688 - }
689 - }
690 -
691 - /// Draw the bulk rename modal: pattern-based renaming with live preview.
692 - ///
693 - /// Tokens like `{name}`, `{bpm}`, `{key}` are expanded per-sample. A side-by-side
694 - /// preview grid shows old→new names, updated on every keystroke. The Rename button
695 - /// is disabled when the pattern produces an error (e.g., empty result).
696 - fn draw_bulk_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
697 - let mut should_close = false;
698 - let mut should_execute = false;
699 - let mut pattern_changed = false;
700 -
701 - widgets::modal_window(ctx, "Bulk Rename", true, Some(500.0), |ui| {
702 - if let Some(BulkModal::Rename {
703 - ref mut pattern_input,
704 - ref previews,
705 - ref error,
706 - ..
707 - }) = state.bulk_modal
708 - {
709 - ui.heading("Rename Pattern");
710 - ui.add_space(theme::space::bound());
711 -
712 - ui.horizontal_wrapped(|ui| {
713 - for token in &[
714 - "{name}",
715 - "{ext}",
716 - "{bpm}",
717 - "{key}",
718 - "{class}",
719 - "{duration}",
720 - "{n}",
721 - "{nn}",
722 - "{nnn}",
723 - ] {
724 - if ui
725 - .small_button(egui::RichText::new(*token).small().color(theme::action()))
726 - .clicked()
727 - {
728 - pattern_input.push_str(token);
729 - pattern_changed = true;
730 - }
731 - }
732 - });
733 - ui.add_space(theme::space::bound());
734 -
735 - let resp = widgets::text_field(
736 - ui,
737 - egui::TextEdit::singleline(pattern_input)
738 - .hint_text("{name}_{bpm}")
739 - .desired_width(460.0),
740 - );
741 - if resp.changed() {
742 - pattern_changed = true;
743 - }
744 -
745 - if let Some(err) = error {
746 - ui.colored_label(theme::danger(), err);
747 - }
748 -
749 - ui.add_space(theme::space::bound());
750 -
751 - if !previews.is_empty() {
752 - // M-8: count duplicate output names once per frame so we can
753 - // highlight colliding rows. Counting once is the whole point,
754 - // doing it per-row would be O(n^2).
755 - let mut new_counts: std::collections::HashMap<&str, usize> =
756 - std::collections::HashMap::with_capacity(previews.len());
757 - for (_, new) in previews {
758 - *new_counts.entry(new.as_str()).or_insert(0) += 1;
759 - }
760 -
761 - // M-7: cap the rendered preview to keep the modal responsive
762 - // on big selections. egui::Grid materialises every cell every
763 - // frame; for a 500-row rename this matters.
764 - const PREVIEW_CAP: usize = 50;
765 - let total = previews.len();
766 - let visible_count = total.min(PREVIEW_CAP);
767 -
768 - egui::ScrollArea::vertical()
769 - .max_height(200.0)
770 - .show(ui, |ui| {
771 - egui::Grid::new("rename_preview")
772 - .striped(true)
773 - .show(ui, |ui| {
774 - ui.label(
775 - egui::RichText::new("Old")
776 - .strong()
777 - .color(theme::content_secondary()),
778 - );
779 - ui.label(
780 - egui::RichText::new("New").strong().color(theme::action()),
781 - );
782 - ui.end_row();
783 -
784 - for (old, new) in previews.iter().take(visible_count) {
785 - ui.label(old);
786 - // M-8: duplicate output names render in
787 - // warning with an honest hover,
788 - // backend collision behaviour isn't
789 - // verified here, so we describe risk not
790 - // outcome.
791 - let is_dup =
792 - new_counts.get(new.as_str()).copied().unwrap_or(0) > 1;
793 - let color = if is_dup {
794 - theme::warning()
795 - } else {
796 - theme::action()
797 - };
798 - let label = ui.label(egui::RichText::new(new).color(color));
799 - if is_dup {
800 - label.on_hover_text(
801 - "Duplicate output name: rename will collide on commit.",
802 - );
803 - }
804 - ui.end_row();
805 - }
806 - });
807 -
808 - // M-7: muted overflow notice when we've capped the preview.
809 - if total > visible_count {
810 - ui.add_space(theme::space::hair());
811 - ui.label(
812 - egui::RichText::new(format!(
813 - "...and {} more (preview only shows the first {}).",
814 - total - visible_count,
815 - PREVIEW_CAP,
816 - ))
817 - .small()
818 - .color(theme::content_muted()),
819 - );
820 - }
821 - });
822 - }
823 -
824 - ui.add_space(theme::space::peer());
825 - let can_rename = error.is_none() && !previews.is_empty();
826 - // p-4: Cmd+Enter confirms primary action, gated by can_rename.
827 - if can_rename && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) {
828 - should_execute = true;
829 - }
830 - match widgets::confirm_action_row(ui, "Rename", can_rename, false) {
831 - ConfirmOutcome::Confirmed => should_execute = true,
832 - ConfirmOutcome::Cancelled => should_close = true,
833 - ConfirmOutcome::None => {}
834 - }
835 - }
836 - });
837 -
838 - if pattern_changed {
839 - state.update_rename_previews();
840 - }
841 - if should_execute {
842 - state.execute_bulk_rename();
843 - } else if should_close {
844 - state.close_bulk_modal();
845 - }
846 - }
847 -
848 456 #[cfg(test)]
849 457 mod tests {
850 458 use super::*;