Skip to main content

max / audiofiles

Promote the tag review queue to a full screen It was a collapsing subsection inside the Settings modal. That is the wrong home for a library-wide workflow with its own navigation and bulk actions: it had no room to lay out, and it sat two clicks deep behind a modal that also had to be dismissed to see the library it was talking about. Now an ImportMode variant like the other full-screen workflows. That enum is the app's screen router rather than an import-only state, since it already carries ConfigureExport, ExportComplete and OperationCancelled; a second parallel router would be worse than the misleading name. Layout follows the import review screen: header with the totals and the one queue-wide action, left panel of tags, centre panel of that tag's samples, footer with Close and Rescan. Up/down walk the tag list. The tag stays the unit of navigation and of action, which was the whole point of grouping in the first place. New at this size: check all / uncheck all within a group, and one Accept confident spanning every tag, which is the closest thing left to the auto-apply this layer no longer does and is still a decision made with the count on screen. The queue outlives the screen, so Close is cheap and Reopen skips a library pass that costs seconds. Two entry points: the launcher in Settings > Auto-Tagging, and a toolbar button that appears only once a run has produced something, showing the count rather than a label. Fixes a bug the screen made reachable: accepting from a group slides the render window onto rows whose names were never resolved, so names_loaded has to be cleared or they draw as content hashes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 13:44 UTC
Signed with PGP, not checked
Commit: f7a7c091519b3935fd043c01eb791b661679f300
Parent: 13740e8
9 files changed, +725 insertions, -205 deletions
M Cargo.lock +4 -4
@@ -7297,10 +7297,6 @@
7297 7297 "winnow 1.0.4",
7298 7298 ]
7299 7299
7300 - [[patch.unused]]
7301 - name = "docengine"
7302 - version = "0.4.0"
7303 -
7304 7300 [[patch.unused]]
7305 7301 name = "kberg"
7306 7302 version = "0.1.0"
@@ -7308,3 +7304,7 @@
7308 7304 [[patch.unused]]
7309 7305 name = "painhours"
7310 7306 version = "0.1.0"
7307 +
7308 + [[patch.unused]]
7309 + name = "docengine"
7310 + version = "0.4.0"
@@ -5,7 +5,8 @@
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 7 detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel,
8 - import_screens, instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
8 + import_screens, instrument_panel, layout_strip, overlays, review_library, sidebar, theme,
9 + toolbar,
9 10 };
10 11 use audiofiles_core::vfs::NodeType;
11 12
@@ -106,6 +107,9 @@
106 107 ImportMode::ReviewErrors => {
107 108 import_screens::draw_review_errors(ui, state);
108 109 }
110 + ImportMode::ReviewLibrary { .. } => {
111 + review_library::draw_review_library(ui, state);
112 + }
109 113 ImportMode::OperationCancelled { .. } => {
110 114 import_screens::draw_operation_cancelled(ui, state);
111 115 }
@@ -7,7 +7,7 @@
7 7 MatchMode, NewRule, Rule, RuleAction, RuleCondition, RuleField, RuleOp,
8 8 };
9 9
10 - use super::{BrowserState, ReviewCandidate, ReviewGroup, ReviewQueue, RuleDraft};
10 + use super::{BrowserState, ImportMode, ReviewCandidate, ReviewGroup, ReviewQueue, RuleDraft};
11 11
12 12 /// Which of a group's candidates an accept applies to.
13 13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -240,17 +240,134 @@
240 240 );
241 241 }
242 242
243 - /// Collect what the layer would suggest across the library, writing nothing,
244 - /// into the review queue.
243 + /// Collect what the layer would suggest across the library, writing nothing.
244 + ///
245 + /// Opens the review screen when it finishes and found anything (see
246 + /// `on_classifier_job_done`). Closes the Settings window on the way out:
247 + /// the launcher lives in there, and leaving a modal floating over a
248 + /// full-screen workflow reads as a bug.
245 249 pub fn classifier_review_library(&mut self) {
246 250 use audiofiles_core::analysis::exemplar::DEFAULT_K;
247 251 self.classifier.last_review_accept = None;
252 + self.settings.show_manager = false;
248 253 self.start_classifier_job(
249 254 crate::backend::ClassifierJob::SuggestLibrary { k: DEFAULT_K },
250 255 "Collecting suggestions\u{2026}",
251 256 );
252 257 }
253 258
259 + /// Re-enter the review screen with the queue already in hand.
260 + ///
261 + /// Separate from [`Self::classifier_review_library`] so closing the screen is
262 + /// cheap to undo: the queue survives in `classifier.review`, and a library
263 + /// pass costs seconds on a large library.
264 + pub fn open_review_screen(&mut self) {
265 + if self
266 + .classifier
267 + .review
268 + .as_ref()
269 + .is_none_or(|q| q.groups.is_empty())
270 + {
271 + return;
272 + }
273 + self.settings.show_manager = false;
274 + self.import_wf.import_mode = ImportMode::ReviewLibrary { selected: 0 };
275 + }
276 +
277 + /// Leave the review screen, keeping the queue for a later visit.
278 + pub fn close_review_screen(&mut self) {
279 + if matches!(self.import_wf.import_mode, ImportMode::ReviewLibrary { .. }) {
280 + self.import_wf.import_mode = ImportMode::None;
281 + }
282 + }
283 +
284 + /// Which group the review screen is showing, clamped into range.
285 + ///
286 + /// Accepting a group can empty and remove it, so the stored index outlives
287 + /// what it points at every time the queue shrinks.
288 + pub fn review_selected(&self) -> usize {
289 + let len = self
290 + .classifier
291 + .review
292 + .as_ref()
293 + .map_or(0, |q| q.groups.len());
294 + match self.import_wf.import_mode {
295 + ImportMode::ReviewLibrary { selected } => selected.min(len.saturating_sub(1)),
296 + _ => 0,
297 + }
298 + }
299 +
300 + pub fn set_review_selected(&mut self, index: usize) {
301 + if let ImportMode::ReviewLibrary { ref mut selected } = self.import_wf.import_mode {
302 + *selected = index;
303 + }
304 + }
305 +
306 + /// Accept the confident band of every group at once.
307 + ///
308 + /// The one bulk gesture that spans the whole queue, and the closest thing to
309 + /// the auto-apply this layer no longer does. Deliberately still a decision the
310 + /// user makes with the counts on screen rather than something that happens to
311 + /// their library on import.
312 + pub fn accept_all_confident(&mut self) {
313 + let Some(queue) = self.classifier.review.as_ref() else {
314 + return;
315 + };
316 + let work: Vec<(String, Vec<String>)> = queue
317 + .groups
318 + .iter()
319 + .map(|g| {
320 + (
321 + g.tag.clone(),
322 + g.candidates
323 + .iter()
324 + .filter(|c| c.confident)
325 + .map(|c| c.hash.clone())
326 + .collect::<Vec<_>>(),
327 + )
328 + })
329 + .filter(|(_, hashes)| !hashes.is_empty())
330 + .collect();
331 + if work.is_empty() {
332 + return;
333 + }
334 +
335 + let mut total = 0usize;
336 + for (tag, hashes) in &work {
337 + let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
338 + match self.backend.bulk_add_tag(&refs, tag) {
339 + Ok(n) => total += n,
340 + Err(e) => {
341 + self.classifier.last_error = Some(format!("Could not accept {tag}: {e}"));
342 + self.status = format!("Could not accept {tag}: {e}");
343 + return;
344 + }
345 + }
346 + }
347 +
348 + if let Some(queue) = self.classifier.review.as_mut() {
349 + for group in &mut queue.groups {
350 + group.candidates.retain(|c| !c.confident);
351 + // See `accept_review`: the render window moves, so resolved names
352 + // no longer line up with the rows that will be drawn.
353 + group.names_loaded = false;
354 + }
355 + queue.groups.retain(|g| !g.candidates.is_empty());
356 + }
357 + self.classifier.last_review_accept =
358 + Some(format!("Accepted {total} across {} tags", work.len()));
359 + self.status = format!("Accepted {total} tag{}.", if total == 1 { "" } else { "s" });
360 + self.refresh_contents();
361 + self.refresh_selected_tags();
362 + }
363 +
364 + /// Confident candidates across every group, for the queue-wide accept button.
365 + pub fn review_confident_total(&self) -> usize {
366 + self.classifier.review.as_ref().map_or(0, |q| {
367 + q.groups.iter().map(super::ReviewGroup::confident).sum()
368 + })
369 + }
370 +
254 371 /// Resolve display names for the first `limit` candidates of one group, on
255 372 /// first expand.
256 373 ///
@@ -335,6 +452,10 @@
335 452 && let Some(group) = queue.groups.get_mut(group_index)
336 453 {
337 454 group.candidates.retain(|c| !accepted.contains(&c.hash));
455 + // Removing candidates slides the render window down onto rows whose
456 + // names were never resolved, so the group has to be re-resolved or
457 + // they draw as content hashes.
458 + group.names_loaded = false;
338 459 }
339 460 self.classifier.last_review_accept = Some(format!("Accepted {n} \u{00b7} {tag}"));
340 461 self.status = format!("Accepted {n} tag{}.", if n == 1 { "" } else { "s" });
@@ -458,6 +579,9 @@
458 579 samples_considered: suggestions.samples_considered,
459 580 samples_with_suggestions: suggestions.samples_with_suggestions,
460 581 });
582 + if total > 0 {
583 + self.import_wf.import_mode = ImportMode::ReviewLibrary { selected: 0 };
584 + }
461 585 self.status = if total == 0 {
462 586 "No suggestions. Tag a few more samples and try again.".to_string()
463 587 } else {
@@ -3379,7 +3379,8 @@
3379 3379 /// The library review queue: what the suggest-only path does when the user acts.
3380 3380 mod review_queue {
3381 3381 use super::*;
3382 - use crate::state::ReviewSelection;
3382 + use crate::state::{ImportMode, ReviewSelection};
3383 + use crate::ui::review_library::RENDER_ROWS;
3383 3384
3384 3385 /// A queue with one group of three, the first two confident.
3385 3386 fn seeded(state: &mut BrowserState) {
@@ -3516,7 +3517,7 @@
3516 3517 seeded(&mut state);
3517 3518 state.accept_review(9, ReviewSelection::All);
3518 3519 state.dismiss_review_group(9);
3519 - state.ensure_review_names(9, 200);
3520 + state.ensure_review_names(9, RENDER_ROWS);
3520 3521 assert_eq!(
3521 3522 state.classifier.review.as_ref().unwrap().groups.len(),
3522 3523 1,
@@ -3534,10 +3535,159 @@
3534 3535 .is_none()
3535 3536 );
3536 3537
3537 - state.ensure_review_names(0, 200);
3538 + state.ensure_review_names(0, RENDER_ROWS);
3538 3539
3539 3540 let group = &state.classifier.review.as_ref().unwrap().groups[0];
3540 3541 assert!(group.names_loaded);
3541 3542 assert_eq!(group.candidates[0].name.as_deref(), Some("a.wav"));
3542 3543 }
3544 +
3545 + // --- The full screen ---
3546 +
3547 + #[test]
3548 + fn opening_the_screen_needs_a_non_empty_queue() {
3549 + let (mut state, _dir) = make_state();
3550 + state.open_review_screen();
3551 + assert!(
3552 + matches!(state.import_wf.import_mode, ImportMode::None),
3553 + "an empty queue must not open a screen with nothing on it"
3554 + );
3555 +
3556 + seeded(&mut state);
3557 + state.open_review_screen();
3558 + assert!(matches!(
3559 + state.import_wf.import_mode,
3560 + ImportMode::ReviewLibrary { selected: 0 }
3561 + ));
3562 + }
3563 +
3564 + #[test]
3565 + fn opening_the_screen_closes_the_settings_window() {
3566 + // The launcher lives inside Settings, and a modal floating over a
3567 + // full-screen workflow reads as a bug.
3568 + let (mut state, _dir) = make_state();
3569 + seeded(&mut state);
3570 + state.settings.show_manager = true;
3571 + state.open_review_screen();
3572 + assert!(!state.settings.show_manager);
3573 + }
3574 +
3575 + #[test]
3576 + fn closing_the_screen_keeps_the_queue() {
3577 + // A library pass costs seconds; closing must not throw it away.
3578 + let (mut state, _dir) = make_state();
3579 + seeded(&mut state);
3580 + state.open_review_screen();
3581 + state.close_review_screen();
3582 +
3583 + assert!(matches!(state.import_wf.import_mode, ImportMode::None));
3584 + assert_eq!(
3585 + state.classifier.review.as_ref().unwrap().groups.len(),
3586 + 1,
3587 + "the queue survives"
3588 + );
3589 + }
3590 +
3591 + #[test]
3592 + fn close_leaves_other_screens_alone() {
3593 + let (mut state, _dir) = make_state();
3594 + state.import_wf.import_mode = ImportMode::ReviewErrors;
3595 + state.close_review_screen();
3596 + assert!(matches!(
3597 + state.import_wf.import_mode,
3598 + ImportMode::ReviewErrors
3599 + ));
3600 + }
3601 +
3602 + #[test]
3603 + fn the_selected_group_is_clamped_when_the_queue_shrinks() {
3604 + // Accepting a group removes it, so a stored index outlives what it points
3605 + // at every time the queue shrinks.
3606 + let (mut state, _dir) = make_state();
3607 + seeded(&mut state);
3608 + state.open_review_screen();
3609 + state.set_review_selected(5);
3610 + assert_eq!(state.review_selected(), 0, "clamped to the only group");
3611 +
3612 + state.dismiss_review_group(0);
3613 + assert_eq!(state.review_selected(), 0, "and does not panic when empty");
3614 + }
3615 +
3616 + #[test]
3617 + fn accept_all_confident_spans_every_group() {
3618 + let (mut state, _dir) = make_state();
3619 + seeded(&mut state);
3620 + // A second group, one confident member.
3621 + insert_fake_sample(&state, "d");
3622 + state
3623 + .classifier
3624 + .review
3625 + .as_mut()
3626 + .unwrap()
3627 + .groups
3628 + .push(ReviewGroup {
3629 + tag: "instrument.bass".to_string(),
3630 + candidates: vec![ReviewCandidate {
3631 + hash: "d".into(),
3632 + name: None,
3633 + score: 0.99,
3634 + confident: true,
3635 + accepted: false,
3636 + }],
3637 + expanded: false,
3638 + names_loaded: false,
3639 + });
3640 +
3641 + assert_eq!(state.review_confident_total(), 3);
3642 + state.accept_all_confident();
3643 +
3644 + assert_eq!(
3645 + tags_of(&state, "a"),
3646 + vec!["instrument.drum.kick".to_string()]
3647 + );
3648 + assert_eq!(
3649 + tags_of(&state, "b"),
3650 + vec!["instrument.drum.kick".to_string()]
3651 + );
3652 + assert_eq!(tags_of(&state, "d"), vec!["instrument.bass".to_string()]);
3653 + assert!(
3654 + tags_of(&state, "c").is_empty(),
3655 + "the review band is untouched"
3656 + );
3657 +
3658 + // The emptied group is gone; the one with a leftover stays.
3659 + let groups = &state.classifier.review.as_ref().unwrap().groups;
3660 + assert_eq!(groups.len(), 1);
3661 + assert_eq!(groups[0].candidates.len(), 1);
3662 + assert_eq!(groups[0].candidates[0].hash, "c");
3663 + }
3664 +
3665 + #[test]
3666 + fn accept_all_confident_with_nothing_confident_is_a_no_op() {
3667 + let (mut state, _dir) = make_state();
3668 + seeded(&mut state);
3669 + for c in &mut state.classifier.review.as_mut().unwrap().groups[0].candidates {
3670 + c.confident = false;
3671 + }
3672 + state.accept_all_confident();
3673 + assert!(tags_of(&state, "a").is_empty());
3674 + assert!(state.classifier.last_review_accept.is_none());
3675 + }
3676 +
3677 + #[test]
3678 + fn accepting_reopens_name_resolution_for_the_rows_that_slide_up() {
3679 + // The render window is bounded, so removing candidates exposes rows whose
3680 + // names were never fetched. Leaving names_loaded set would draw them as
3681 + // content hashes.
3682 + let (mut state, _dir) = make_state();
3683 + seeded(&mut state);
3684 + state.ensure_review_names(0, RENDER_ROWS);
3685 + assert!(state.classifier.review.as_ref().unwrap().groups[0].names_loaded);
3686 +
3687 + state.accept_review(0, ReviewSelection::Confident);
3688 + assert!(
3689 + !state.classifier.review.as_ref().unwrap().groups[0].names_loaded,
3690 + "names must be re-resolved after the window moves"
3691 + );
3692 + }
3543 3693 }
@@ -1088,6 +1088,19 @@
1088 1088 errors: Vec<(String, String)>,
1089 1089 },
1090 1090 ReviewErrors,
1091 + /// The library-wide tag review queue, as a full screen.
1092 + ///
1093 + /// Not part of the import wizard despite living on this enum. `ImportMode` is
1094 + /// the app's full-screen router rather than an import-only state (it already
1095 + /// carries `ConfigureExport`, `ExportComplete` and `OperationCancelled`), and
1096 + /// a second parallel router would be worse than the misleading name.
1097 + ///
1098 + /// The queue itself lives in `classifier.review`, not here, so closing the
1099 + /// screen keeps it and re-opening is instant instead of a fresh library pass.
1100 + /// This holds only which group is being read.
1101 + ReviewLibrary {
1102 + selected: usize,
1103 + },
1091 1104 /// Acknowledgement screen after the user cancels a long-running operation.
1092 1105 /// Surfaces what landed vs what was discarded so the user isn't left to
1093 1106 /// guess whether to re-run, restore, or move on. `destination` is set only
@@ -8,7 +8,7 @@
8 8
9 9 use super::theme;
10 10 use super::widgets;
11 - use crate::state::{BrowserState, ReviewSelection};
11 + use crate::state::BrowserState;
12 12
13 13 /// Fields offered in the condition builder (path/name + tags first, then DSP).
14 14 const FIELDS: &[RuleField] = &[
@@ -561,7 +561,7 @@
561 561 }
562 562
563 563 ui.add_space(theme::space::peer());
564 - draw_review_queue(ui, state);
564 + draw_review_launcher(ui, state);
565 565
566 566 ui.add_space(theme::space::peer());
567 567 draw_trained_head(ui, state);
@@ -628,27 +628,18 @@
628 628 });
629 629 }
630 630
631 - /// Rows drawn per expanded group.
631 + /// "Review suggestions" launcher.
632 632 ///
633 - /// A drawing and name-resolution budget, not a cap on the group: every button
634 - /// acts on all of it. Names cost one backend call each, so resolving a
635 - /// 44,000-row group to draw a couple of screens would stall the frame.
636 - const REVIEW_RENDER_ROWS: usize = 200;
637 -
638 - /// "Review suggestions" subsection: what the layer would tag, grouped by tag,
639 - /// accepted in bulk.
640 - ///
641 - /// Grouped by tag rather than by sample on purpose, and it is the whole reason
642 - /// the screen works. "Are these 340 all kicks?" is a question a scan of one list
643 - /// answers; the same 340 rows one sample at a time is 340 questions nobody
644 - /// finishes, which pushes the user to an undifferentiated accept-all and turns
645 - /// the queue back into auto-apply with extra steps.
646 - fn draw_review_queue(ui: &mut egui::Ui, state: &mut BrowserState) {
633 + /// The queue itself is a full screen (`ui::review_library`), not a subsection.
634 + /// It is a library-wide workflow with its own navigation and bulk actions, and
635 + /// it does not fit, or deserve to be buried, inside a collapsing header behind a
636 + /// modal. This is the door.
637 + fn draw_review_launcher(ui: &mut egui::Ui, state: &mut BrowserState) {
647 638 widgets::subsection_label(ui, "Review suggestions");
648 639 ui.label(
649 640 egui::RichText::new(
650 - "Collects what auto-tagging would apply, without applying any of it. \
651 - Accept a whole group at once, or pick from it.",
641 + "Collects what auto-tagging would apply, without applying any of it, \
642 + then opens a screen to accept it a tag at a time.",
652 643 )
653 644 .small()
654 645 .color(theme::content_muted()),
@@ -656,6 +647,13 @@
656 647 ui.add_space(theme::space::bound());
657 648
658 649 let busy = state.classifier.busy.is_some();
650 + let pending = state
651 + .classifier
652 + .review
653 + .as_ref()
654 + .map(|q| q.groups.iter().map(|g| g.candidates.len()).sum::<usize>())
655 + .filter(|n| *n > 0);
656 +
659 657 ui.horizontal(|ui| {
660 658 if ui
661 659 .add_enabled(!busy, egui::Button::new("Review suggestions"))
@@ -664,14 +662,17 @@
664 662 {
665 663 state.classifier_review_library();
666 664 }
667 - if state.classifier.review.is_some()
668 - && ui.add_enabled(!busy, egui::Button::new("Clear")).clicked()
665 + // Re-entry without a rescan: the queue outlives the screen, and a pass
666 + // over a large library costs seconds.
667 + if let Some(n) = pending
668 + && ui
669 + .add_enabled(!busy, egui::Button::new(format!("Reopen ({n})")))
670 + .on_hover_text("Back to the queue from the last run")
671 + .clicked()
669 672 {
670 - state.classifier.review = None;
671 - state.classifier.last_review_accept = None;
673 + state.open_review_screen();
672 674 }
673 675 });
674 -
675 676 if let Some(msg) = &state.classifier.last_review_accept {
676 677 ui.label(
677 678 egui::RichText::new(msg)
@@ -679,176 +680,6 @@
679 680 .color(theme::content_muted()),
680 681 );
681 682 }
682 -
683 - let Some(queue) = &state.classifier.review else {
684 - return;
685 - };
686 -
687 - ui.add_space(theme::space::bound());
688 - ui.label(
689 - egui::RichText::new(format!(
690 - "{} of {} sample{} have suggestions.",
691 - queue.samples_with_suggestions,
692 - queue.samples_considered,
693 - if queue.samples_considered == 1 {
694 - ""
695 - } else {
696 - "s"
697 - }
698 - ))
699 - .small()
700 - .color(theme::content_muted()),
701 - );
702 - if queue.groups.is_empty() {
703 - ui.label(
704 - egui::RichText::new(
705 - "Nothing to review. Tag a few more samples so there is something to match against.",
706 - )
707 - .small()
708 - .color(theme::content_muted()),
709 - );
710 - return;
711 - }
712 -
713 - // Deferred so the loop can borrow `state.classifier.review` immutably while
714 - // the actions below need it mutably.
715 - let mut expand: Option<usize> = None;
716 - let mut accept: Option<(usize, ReviewSelection)> = None;
717 - let mut dismiss: Option<usize> = None;
718 - let mut toggle: Option<(usize, usize)> = None;
719 -
720 - for (gi, group) in queue.groups.iter().enumerate() {
721 - ui.add_space(theme::space::bound());
722 - widgets::inset_well(ui, |ui| {
723 - ui.horizontal(|ui| {
724 - let arrow = if group.expanded {
725 - "\u{25be}"
726 - } else {
727 - "\u{25b8}"
728 - };
729 - if ui
730 - .small_button(format!("{arrow} {}", group.tag))
731 - .on_hover_text("Show the samples in this group")
732 - .clicked()
733 - {
734 - expand = Some(gi);
735 - }
736 - ui.label(
737 - egui::RichText::new(format!("{}", group.candidates.len()))
738 - .small()
739 - .color(theme::content_muted()),
740 - );
741 - let confident = group.confident();
742 - if confident > 0 {
743 - ui.label(
744 - egui::RichText::new(format!("{confident} confident"))
745 - .small()
746 - .color(theme::content_muted()),
747 - );
748 - }
749 - });
750 - ui.horizontal(|ui| {
751 - if ui
752 - .small_button(format!("Accept all {}", group.candidates.len()))
753 - .clicked()
754 - {
755 - accept = Some((gi, ReviewSelection::All));
756 - }
757 - let confident = group.confident();
758 - if confident > 0
759 - && confident < group.candidates.len()
760 - && ui
761 - .small_button(format!("Accept {confident} confident"))
762 - .on_hover_text("Only those above this tag's auto threshold")
763 - .clicked()
764 - {
765 - accept = Some((gi, ReviewSelection::Confident));
766 - }
767 - let checked = group.checked();
768 - if checked > 0
769 - && ui
770 - .small_button(format!("Accept {checked} checked"))
771 - .clicked()
772 - {
773 - accept = Some((gi, ReviewSelection::Checked));
774 - }
775 - if widgets::danger_small_button(ui, "Dismiss").clicked() {
776 - dismiss = Some(gi);
777 - }
778 - });
779 -
780 - if !group.expanded {
781 - return;
782 - }
783 - ui.add_space(theme::space::bound());
784 - if group.candidates.len() > REVIEW_RENDER_ROWS {
785 - // The window is a drawing budget, not a limit on the answer: the
786 - // buttons above still act on the whole group. Said out loud so the
787 - // list does not read as the complete set.
788 - ui.label(
789 - egui::RichText::new(format!(
790 - "Showing the {REVIEW_RENDER_ROWS} strongest of {}. The buttons above \
791 - still apply to all {}.",
792 - group.candidates.len(),
793 - group.candidates.len()
794 - ))
795 - .small()
796 - .color(theme::content_muted()),
797 - );
798 - }
799 - egui::ScrollArea::vertical()
800 - .max_height(220.0)
801 - .id_salt(("review-group", gi))
802 - .show(ui, |ui| {
803 - for (ci, c) in group.candidates.iter().take(REVIEW_RENDER_ROWS).enumerate() {
804 - ui.horizontal(|ui| {
805 - let mut checked = c.accepted;
806 - if ui.checkbox(&mut checked, "").changed() {
807 - toggle = Some((gi, ci));
808 - }
809 - ui.label(
810 - egui::RichText::new(c.name.as_deref().unwrap_or(&c.hash)).small(),
811 - );
812 - ui.with_layout(
813 - egui::Layout::right_to_left(egui::Align::Center),
814 - |ui| {
815 - ui.label(
816 - egui::RichText::new(format!("{:.0}%", c.score * 100.0))
817 - .small()
818 - .color(if c.confident {
819 - theme::content()
820 - } else {
821 - theme::content_muted()
822 - }),
823 - );
824 - },
825 - );
826 - });
827 - }
828 - });
829 - });
830 - }
831 -
832 - if let Some(gi) = expand {
833 - if let Some(q) = state.classifier.review.as_mut()
834 - && let Some(g) = q.groups.get_mut(gi)
835 - {
836 - g.expanded = !g.expanded;
837 - }
838 - state.ensure_review_names(gi, REVIEW_RENDER_ROWS);
839 - }
840 - if let Some((gi, ci)) = toggle
841 - && let Some(q) = state.classifier.review.as_mut()
842 - && let Some(c) = q.groups.get_mut(gi).and_then(|g| g.candidates.get_mut(ci))
843 - {
844 - c.accepted = !c.accepted;
845 - }
846 - if let Some((gi, which)) = accept {
847 - state.accept_review(gi, which);
848 - }
849 - if let Some(gi) = dismiss {
850 - state.dismiss_review_group(gi);
851 - }
852 683 }
853 684
854 685 /// "Trained model" subsection: the optional distilled head that speeds up the
@@ -15,6 +15,7 @@
15 15 pub mod instrument_panel;
16 16 pub mod layout_strip;
17 17 pub mod overlays;
18 + pub mod review_library;
18 19 pub mod settings_panel;
19 20 pub mod sidebar;
20 21 pub mod sync_panel;
@@ -580,6 +580,24 @@
580 580 state.start_export_flow(None);
581 581 }
582 582
583 + // Only once a run has produced something. A permanently visible button
584 + // for a screen that is usually empty is clutter, and a count is a better
585 + // invitation than a label: it says there is work waiting, not that a
586 + // feature exists.
587 + let pending: usize = state
588 + .classifier
589 + .review
590 + .as_ref()
591 + .map_or(0, |q| q.groups.iter().map(|g| g.candidates.len()).sum());
592 + if pending > 0
593 + && ui
594 + .button(egui::RichText::new(format!("Review ({pending})")).color(theme::action()))
595 + .on_hover_text("Tag suggestions waiting for review")
596 + .clicked()
597 + {
598 + state.open_review_screen();
599 + }
600 +
583 601 // Sync button, fixed-width so the neighbouring Settings / Help
584 602 // buttons keep their horizontal positions when sync state changes.
585 603 // State communicated by a coloured bullet prefix instead of by
@@ -1,0 +1,379 @@
1 + //! The library-wide tag review screen.
2 + //!
3 + //! What the classifier layer would tag, grouped by tag, accepted in bulk. The
4 + //! bundled `.afcl` layer ships suggest-only, so this is the surface where its
5 + //! answers are seen and decided on; nothing it proposes reaches a library until
6 + //! someone here says so.
7 + //!
8 + //! # Grouped by tag, and that is the whole design
9 + //!
10 + //! "Are these 340 all kicks?" is a question one scan of a list answers. The same
11 + //! 340 rows one sample at a time is 340 questions nobody finishes, and a screen
12 + //! nobody finishes pushes the user to an undifferentiated accept-all, which is
13 + //! auto-apply with extra steps. So the tag is the unit of navigation (left panel)
14 + //! and of action (the buttons), and individual samples exist to be opted out of
15 + //! rather than opted into.
16 + //!
17 + //! Nothing arrives ticked, for the same reason: a screen that opens with 340
18 + //! boxes already checked is auto-apply wearing a checkbox.
19 + //!
20 + //! # Two windows, both deliberate
21 + //!
22 + //! Groups are complete in state, because "Accept all 44,000" has to mean 44,000.
23 + //! Only the drawing is bounded ([`RENDER_ROWS`]), and so is name resolution,
24 + //! which costs a backend call per row. The screen says when it is showing a
25 + //! window, so a partial list never reads as the whole group.
26 +
27 + use egui;
28 +
29 + use super::{theme, widgets};
30 + use crate::state::{BrowserState, ImportMode, ReviewSelection};
31 +
32 + /// Candidate rows drawn for the selected group.
33 + ///
34 + /// A drawing and name-resolution budget, not a cap on the group: every button on
35 + /// this screen acts on all of it. Names are one backend call each, so resolving a
36 + /// 44,000-row group to fill two screens would stall the frame.
37 + pub const RENDER_ROWS: usize = 200;
38 +
39 + pub fn draw_review_library(ui: &mut egui::Ui, state: &mut BrowserState) {
40 + if !matches!(
41 + state.import_wf.import_mode,
42 + ImportMode::ReviewLibrary { .. }
43 + ) {
44 + return;
45 + }
46 + // The queue can empty under the screen: accepting the last group removes it.
47 + // Leaving rather than drawing an empty shell, so "I finished" and "there was
48 + // never anything" do not look the same.
49 + if state
50 + .classifier
51 + .review
52 + .as_ref()
53 + .is_none_or(|q| q.groups.is_empty())
54 + {
55 + state.close_review_screen();
56 + state.status = "Review queue cleared.".to_string();
57 + return;
58 + }
59 +
60 + let ctx = ui.ctx().clone();
61 + let selected = state.review_selected();
62 + state.set_review_selected(selected);
63 +
64 + draw_header(ui, state);
65 + draw_footer(ui, state);
66 + draw_group_list(ui, state, selected, &ctx);
67 + draw_candidates(ui, state, selected);
68 + }
69 +
70 + /// Title, what the pass found, and the one queue-wide action.
71 + fn draw_header(ui: &mut egui::Ui, state: &mut BrowserState) {
72 + let (groups, total, considered, with_suggestions) = {
73 + let q = state.classifier.review.as_ref().expect("checked by caller");
74 + (
75 + q.groups.len(),
76 + q.groups.iter().map(|g| g.candidates.len()).sum::<usize>(),
77 + q.samples_considered,
78 + q.samples_with_suggestions,
79 + )
80 + };
81 + let confident = state.review_confident_total();
82 + let mut accept_confident = false;
83 +
84 + egui::Panel::top("review_library_header").show(ui, |ui| {
85 + ui.horizontal(|ui| {
86 + ui.heading("Review Tags");
87 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
88 + // The queue-wide gesture, and the closest thing left to the
89 + // auto-apply this layer no longer does. Still a decision made
90 + // with the count on screen.
91 + if confident > 0
92 + && ui
93 + .button(format!("Accept {confident} confident"))
94 + .on_hover_text(
95 + "Across every tag: only suggestions above each tag's auto threshold",
96 + )
97 + .clicked()
98 + {
99 + accept_confident = true;
100 + }
101 + });
102 + });
103 + ui.label(
104 + egui::RichText::new(format!(
105 + "{total} suggestion{} across {groups} tag{} \u{00b7} \
106 + {with_suggestions} of {considered} sample{}",
107 + if total == 1 { "" } else { "s" },
108 + if groups == 1 { "" } else { "s" },
109 + if considered == 1 { "" } else { "s" },
110 + ))
111 + .small()
112 + .color(theme::content_muted()),
113 + );
114 + ui.label(
115 + egui::RichText::new("Nothing is applied until you accept it.")
116 + .small()
117 + .color(theme::content_muted()),
118 + );
119 + ui.add_space(theme::space::hair());
120 + });
121 +
122 + if accept_confident {
123 + state.accept_all_confident();
124 + }
125 + }
126 +
127 + fn draw_footer(ui: &mut egui::Ui, state: &mut BrowserState) {
128 + let msg = state.classifier.last_review_accept.clone();
129 + let mut close = false;
130 + let mut rescan = false;
131 +
132 + egui::Panel::bottom("review_library_footer").show(ui, |ui| {
133 + ui.add_space(theme::space::bound());
134 + ui.horizontal(|ui| {
135 + if ui
136 + .button("Close")
137 + .on_hover_text("Keeps the queue: re-open it from Settings > Auto-Tagging")
138 + .clicked()
139 + {
140 + close = true;
141 + }
142 + if ui
143 + .add_enabled(
144 + state.classifier.busy.is_none(),
145 + egui::Button::new("Rescan library"),
146 + )
147 + .on_hover_text("Run the pass again. Writes nothing.")
148 + .clicked()
149 + {
150 + rescan = true;
151 + }
152 + if let Some(msg) = &msg {
153 + ui.label(
154 + egui::RichText::new(msg)
155 + .small()
156 + .color(theme::content_muted()),
157 + );
158 + }
159 + });
160 + ui.add_space(theme::space::hair());
161 + });
162 +
163 + if close {
164 + state.close_review_screen();
165 + }
166 + if rescan {
167 + state.classifier_review_library();
168 + }
169 + }
170 +
171 + /// The tag list: the screen's unit of navigation.
172 + fn draw_group_list(
173 + ui: &mut egui::Ui,
174 + state: &mut BrowserState,
175 + selected: usize,
176 + ctx: &egui::Context,
177 + ) {
178 + // Up/down walk the tags, matching the import review screen. Suppressed while
179 + // a text field owns focus so typing is never hijacked.
180 + let nav: i32 = if ctx.memory(|m| m.focused().is_some()) {
181 + 0
182 + } else {
183 + let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp));
184 + let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown));
185 + match (up, down) {
186 + (true, false) => -1,
187 + (false, true) => 1,
188 + _ => 0,
189 + }
190 + };
191 +
192 + let mut clicked: Option<usize> = None;
193 + egui::Panel::left("review_library_tags")
194 + .resizable(true)
195 + .default_size(240.0)
196 + .show(ui, |ui| {
197 + widgets::subsection_label(ui, "Tags");
198 + egui::ScrollArea::vertical().show(ui, |ui| {
199 + let Some(queue) = state.classifier.review.as_ref() else {
200 + return;
201 + };
202 + for (i, group) in queue.groups.iter().enumerate() {
203 + let is_selected = i == selected;
204 + let confident = group.confident();
205 + let response = ui.selectable_label(
206 + is_selected,
207 + egui::RichText::new(format!(
208 + "{}\n{} suggestion{}{}",
209 + group.tag,
210 + group.candidates.len(),
211 + if group.candidates.len() == 1 { "" } else { "s" },
212 + if confident > 0 {
213 + format!(", {confident} confident")
214 + } else {
215 + String::new()
216 + }
217 + ))
218 + .small(),
219 + );
220 + if response.clicked() {
221 + clicked = Some(i);
222 + }
223 + }
224 + });
225 + });
226 +
227 + let count = state
228 + .classifier
229 + .review
230 + .as_ref()
231 + .map_or(0, |q| q.groups.len());
232 + let target = clicked.unwrap_or_else(|| {
233 + let next = i64::try_from(selected).unwrap_or(0) + i64::from(nav);
234 + usize::try_from(next.clamp(0, i64::try_from(count.saturating_sub(1)).unwrap_or(0)))
235 + .unwrap_or(0)
236 + });
237 + if target != selected || clicked.is_some() {
238 + state.set_review_selected(target);
239 + }
240 + state.ensure_review_names(target, RENDER_ROWS);
241 + }
242 +
243 + /// The selected group: its actions, then its samples.
244 + fn draw_candidates(ui: &mut egui::Ui, state: &mut BrowserState, selected: usize) {
245 + let mut accept: Option<ReviewSelection> = None;
246 + let mut dismiss = false;
247 + let mut toggle: Option<usize> = None;
248 + let mut set_all_checked: Option<bool> = None;
249 +
250 + egui::CentralPanel::default().show(ui, |ui| {
251 + let Some(group) = state
252 + .classifier
253 + .review
254 + .as_ref()
255 + .and_then(|q| q.groups.get(selected))
256 + else {
257 + return;
258 + };
259 + let count = group.candidates.len();
260 + let confident = group.confident();
261 + let checked = group.checked();
262 +
263 + ui.horizontal(|ui| {
264 + ui.heading(&group.tag);
265 + });
266 + ui.label(
267 + egui::RichText::new(format!(
268 + "{count} sample{} would get this tag.",
269 + if count == 1 { "" } else { "s" }
270 + ))
271 + .small()
272 + .color(theme::content_muted()),
273 + );
274 + ui.add_space(theme::space::bound());
275 +
276 + ui.horizontal(|ui| {
277 + if widgets::primary_button(ui, &format!("Accept all {count}")).clicked() {
278 + accept = Some(ReviewSelection::All);
279 + }
280 + // Only worth offering when it is a real subset; otherwise it is a
281 + // second button that does what the first one does.
282 + if confident > 0
283 + && confident < count
284 + && ui
285 + .button(format!("Accept {confident} confident"))
286 + .on_hover_text("Only those above this tag's auto threshold")
287 + .clicked()
288 + {
289 + accept = Some(ReviewSelection::Confident);
290 + }
291 + if checked > 0 && ui.button(format!("Accept {checked} checked")).clicked() {
292 + accept = Some(ReviewSelection::Checked);
293 + }
294 + if widgets::danger_small_button(ui, "Dismiss tag").clicked() {
295 + dismiss = true;
296 + }
297 + });
298 +
299 + ui.add_space(theme::space::bound());
300 + ui.horizontal(|ui| {
301 + if ui.small_button("Check all").clicked() {
302 + set_all_checked = Some(true);
303 + }
304 + if checked > 0 && ui.small_button("Uncheck all").clicked() {
305 + set_all_checked = Some(false);
306 + }
307 + if count > RENDER_ROWS {
308 + ui.label(
309 + egui::RichText::new(format!(
310 + "Showing the {RENDER_ROWS} strongest. The buttons above apply to all \
311 + {count}.",
312 + ))
313 + .small()
314 + .color(theme::content_muted()),
315 + );
316 + }
317 + });
318 + ui.add_space(theme::space::bound());
319 +
320 + egui::ScrollArea::vertical()
321 + .id_salt(("review-library-candidates", selected))
322 + .show(ui, |ui| {
323 + for (i, c) in group.candidates.iter().take(RENDER_ROWS).enumerate() {
324 + ui.horizontal(|ui| {
325 + let mut ticked = c.accepted;
326 + if ui.checkbox(&mut ticked, "").changed() {
327 + toggle = Some(i);
328 + }
329 + ui.label(egui::RichText::new(c.name.as_deref().unwrap_or(&c.hash)).small());
330 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
331 + // Confident rows read at full contrast, review-band
332 + // rows muted, so the band is visible without a second
333 + // column of words.
334 + ui.label(
335 + egui::RichText::new(format!("{:.0}%", c.score * 100.0))
336 + .small()
337 + .color(if c.confident {
338 + theme::content()
339 + } else {
340 + theme::content_muted()
341 + }),
342 + );
343 + });
344 + });
345 + }
346 + });
347 + });
348 +
349 + if let Some(all) = set_all_checked
350 + && let Some(group) = state
351 + .classifier
352 + .review
353 + .as_mut()
354 + .and_then(|q| q.groups.get_mut(selected))
355 + {
356 + // Only the drawn rows: ticking 44,000 invisible boxes so that "Accept
357 + // checked" silently means "accept everything" would defeat the point of
358 + // having a separate all-vs-checked distinction.
359 + for c in group.candidates.iter_mut().take(RENDER_ROWS) {
360 + c.accepted = all;
361 + }
362 + }
363 + if let Some(i) = toggle
364 + && let Some(c) = state
365 + .classifier
366 + .review
367 + .as_mut()
368 + .and_then(|q| q.groups.get_mut(selected))
369 + .and_then(|g| g.candidates.get_mut(i))
370 + {
371 + c.accepted = !c.accepted;
372 + }
373 + if let Some(which) = accept {
374 + state.accept_review(selected, which);
375 + }
376 + if dismiss {
377 + state.dismiss_review_group(selected);
378 + }
379 + }