| 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 |
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::*;
|