Skip to main content

max / audiofiles

Flip the library tag queue to the described screen `ImportMode::ReviewLibrary` serves from `quasi::queue` and `ui/review_library.rs` is gone. `RENDER_ROWS` came out of it rather than going with it: the described screen reads it and `ensure_review_names` takes it as an argument, so it is a fact about how much of a group is worth resolving rather than about a renderer. The queue is the first flipped screen that is not a window, so `panel::window` splits: `drive` is a described screen's frame with no opinion about where it is, `window` puts a window round it and `inline` does not. The import and export flows want the same seam when their turn comes. Parity held with two named differences. The shipped "Check all" ticks the window the screen is showing rather than the group, which can be 44,000 rows; the described one says "Check all shown", which is what `Queue::tick_shown` was already called. And the group row carries its counts as parts rather than folded into the button's label with a newline. One allowance is a defect rather than a difference, and it is filed: `quasi_immediate::node::row` claims a row's strip with a bare `ui.interact`, which registers no `WidgetInfo`, so a pressable row reaches the widget tree as nothing at all. It works under a mouse and does not exist for a keyboard or a screen reader.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 17:24 UTC
Signed with PGP, not checked
Commit: 43d7bba76c9e246b2abc0fa6e8275d53ee59955a
Parent: 76a7a1f
8 files changed, +203 insertions, -501 deletions
@@ -5,8 +5,7 @@
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, review_library, sidebar, theme,
9 - toolbar,
8 + import_screens, instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
10 9 };
11 10 use audiofiles_core::vfs::NodeType;
12 11
@@ -108,7 +107,7 @@
108 107 import_screens::draw_review_errors(ui, state);
109 108 }
110 109 ImportMode::ReviewLibrary { .. } => {
111 - review_library::draw_review_library(ui, state);
110 + crate::quasi::panel::draw_queue(ui, state);
112 111 }
113 112 ImportMode::OperationCancelled { .. } => {
114 113 import_screens::draw_operation_cancelled(ui, state);
@@ -257,17 +256,6 @@
257 256 crate::quasi::panel::draw_sweep(ctx, state);
258 257 }
259 258
260 - // The described tag queue, beside the shipped review screen. Same terms as
261 - // the two flows: the shipped side takes over the central pane rather than
262 - // being a window, so this opens when that screen does.
263 - #[cfg(feature = "quasi")]
264 - if matches!(
265 - state.import_wf.import_mode,
266 - crate::state::ImportMode::ReviewLibrary { .. }
267 - ) {
268 - crate::quasi::panel::draw_queue(ctx, state);
269 - }
270 -
271 259 // Sync panel overlay
272 260 if state.sync.show_panel {
273 261 // The described one beside it, on the same toggle. `None` is the case
@@ -4162,7 +4162,7 @@
4162 4162 group
4163 4163 .candidates
4164 4164 .iter()
4165 - .take(crate::ui::review_library::RENDER_ROWS)
4165 + .take(crate::quasi::queue::RENDER_ROWS)
4166 4166 .map(|candidate| Candidate {
4167 4167 // The hash stands in until the name is resolved, which
4168 4168 // is the shipped row's own fallback.
@@ -364,11 +364,15 @@
364 364 }
365 365 }
366 366
367 - /// Draw the described tag queue, and act on whatever was pressed.
367 + /// Draw the tag review queue, and act on whatever was pressed.
368 368 ///
369 - /// **Refreshed unconditionally**, and the reason is the rescan: the pass runs on
370 - /// a worker and the queue it builds arrives with nothing pressed.
371 - pub fn draw_queue(ctx: &egui::Context, state: &mut BrowserState) {
369 + /// Into the app's own pane rather than a window, because that is what the
370 + /// shipped screen was: `ImportMode::ReviewLibrary` is a full-screen mode and
371 + /// the main pane is where a mode is drawn.
372 + ///
373 + /// Refreshed unconditionally: the classifier worker fills the queue and drains
374 + /// it while the screen is up, so what it shows moves with nothing pressed.
375 + pub fn draw_queue(ui: &mut egui::Ui, state: &mut BrowserState) {
372 376 let intents = RefCell::new(Vec::new());
373 377 let mut runtime = state.described.queue.take();
374 378 let host = Host {
@@ -377,19 +381,9 @@
377 381 themes: themes(),
378 382 intents: &intents,
379 383 };
380 - let closed = window(
381 - ctx,
382 - "Review Tags (described)",
383 - &mut runtime,
384 - &host,
385 - "/review",
386 - true,
387 - );
384 + inline(ui, &mut runtime, &host, "/review", true);
388 385 state.described.queue = runtime;
389 - apply(ctx, state, None, intents.into_inner());
390 - if closed {
391 - state.described.queue = None;
392 - }
386 + apply(ui.ctx(), state, None, intents.into_inner());
393 387 }
394 388
395 389 /// Draw the described filter panel, and act on whatever was pressed.
@@ -1379,7 +1373,7 @@
1379 1373 // and a route holding `&S` could not.
1380 1374 Intent::ReadGroup(at) => {
1381 1375 state.set_review_selected(at);
1382 - state.ensure_review_names(at, crate::ui::review_library::RENDER_ROWS);
1376 + state.ensure_review_names(at, crate::quasi::queue::RENDER_ROWS);
1383 1377 }
1384 1378 Intent::TickCandidate(at) => {
1385 1379 let selected = state.review_selected();
@@ -1407,7 +1401,7 @@
1407 1401 for candidate in group
1408 1402 .candidates
1409 1403 .iter_mut()
1410 - .take(crate::ui::review_library::RENDER_ROWS)
1404 + .take(crate::quasi::queue::RENDER_ROWS)
1411 1405 {
1412 1406 candidate.accepted = ticked;
1413 1407 }
@@ -1756,6 +1750,100 @@
1756 1750 intents: &'a RefCell<Vec<Intent>>,
1757 1751 }
1758 1752
1753 + /// Drive one described screen into a `Ui`: draw it, and act on what was pressed.
1754 + ///
1755 + /// The whole of a described screen's frame, with no opinion about where it is.
1756 + /// [`window`] puts a window round it and [`inline`] does not, which is the only
1757 + /// difference between a described modal and a described full-screen mode: the
1758 + /// app's own arrangement, not the screen's.
1759 + fn drive(
1760 + ui: &mut egui::Ui,
1761 + runtime: &mut Option<Runtime>,
1762 + host: &Host<'_>,
1763 + home: &str,
1764 + refresh: bool,
1765 + ) {
1766 + let immediate = Immediate::new(theme::palette());
1767 +
1768 + // The first frame has no screen yet, so it asks for one. Everything
1769 + // after it is the loop below.
1770 + let runtime = match runtime {
1771 + Some(runtime) => runtime,
1772 + none => match answer(host, Request::get(home)) {
1773 + Ok(response) => match response.outcome {
1774 + // The app's keys, bound to the one table `help::chrome`
1775 + // holds and the help overlay lists. Every described
1776 + // window gets them, which is what "works from every
1777 + // screen" means for an app that has several.
1778 + // `Over` as well as `Screen`, because a screen that is
1779 + // an overlay everywhere else is the whole of this window
1780 + // when the window is what the app opened for it. "Over"
1781 + // says what a screen is drawn on top of, and a modal
1782 + // given a window of its own is drawn on top of the app.
1783 + //
1784 + // Missing until 2026-08-22, and it did not matter while
1785 + // every described window was a `Screen`: the first flip
1786 + // pointed a window at `/library/loose-files`, which is
1787 + // an `Over`, and the window drew the outcome's `Debug`
1788 + // rendering instead of the screen.
1789 + quasi_router::Outcome::Screen(screen) | quasi_router::Outcome::Over(screen) => {
1790 + none.insert(Runtime::new(screen).with_chrome(super::help::chrome()))
1791 + }
1792 + other => {
1793 + ui.label(format!("the home address answered {other:?}"));
1794 + return;
1795 + }
1796 + },
1797 + Err(message) => {
1798 + ui.label(message);
1799 + return;
1800 + }
1801 + },
1802 + };
1803 +
1804 + // Before the drawing, so the frame draws what is true now rather
1805 + // than showing the previous answer for one more frame. `reload`
1806 + // re-asks the address the screen came from, and the runtime keeps
1807 + // what the user has typed and ticked across it.
1808 + //
1809 + // **Never while an overlay is open.** `reload` re-asks the address
1810 + // the screen came from, and an overlay is not a place, so that
1811 + // address is the screen *underneath* -- which answers
1812 + // `Outcome::Screen`, which clears the layer stack. An unconditional
1813 + // refresh would take the modal down on the frame after it opened.
1814 + // See `bulk`'s header, finding 2.
1815 + if refresh && !runtime.overlaid() {
1816 + let step = runtime.reload();
1817 + perform(runtime, ui, host, step);
1818 + }
1819 +
1820 + // A screen that says it is live is re-asked on the renderer's own
1821 + // cadence, which is `quasi_immediate::CADENCE` and is paced by the
1822 + // runtime rather than by anything here. This is the whole of what
1823 + // the sync panel's finding asked for: its state moves when an OAuth
1824 + // callback lands in another process, and until now nothing but an
1825 + // intent or a per-frame reload would notice.
1826 + //
1827 + // Under the same overlay guard as the refresh above, and for the
1828 + // same reason: a live screen's address is the screen underneath an
1829 + // open modal, and asking for it would take the modal down.
1830 + if !runtime.overlaid() {
1831 + for request in runtime.refreshes() {
1832 + call(runtime, host, request);
1833 + }
1834 + }
1835 +
1836 + let step = runtime.show(ui, &immediate);
1837 + perform(runtime, ui, host, step);
1838 +
1839 + // One drain for every described window, rather than one per
1840 + // `draw_*`: a file is produced by a route and a route is reachable
1841 + // from all of them, so the host answer belongs where the runtime is
1842 + // driven. Last in the frame because `perform` above is what may have
1843 + // just produced one.
1844 + hand_over(runtime, host.state);
1845 + }
1846 +
1759 1847 /// One described window: draw it, act on it, and say whether it was closed.
1760 1848 fn window(
1761 1849 ctx: &egui::Context,
@@ -1770,92 +1858,26 @@
1770 1858 egui::Window::new(title)
1771 1859 .open(&mut open)
1772 1860 .default_width(420.0)
1773 - .show(ctx, |ui| {
1774 - let immediate = Immediate::new(theme::palette());
1775 -
1776 - // The first frame has no screen yet, so it asks for one. Everything
1777 - // after it is the loop below.
1778 - let runtime = match runtime {
1779 - Some(runtime) => runtime,
1780 - none => match answer(host, Request::get(home)) {
1781 - Ok(response) => match response.outcome {
1782 - // The app's keys, bound to the one table `help::chrome`
1783 - // holds and the help overlay lists. Every described
1784 - // window gets them, which is what "works from every
1785 - // screen" means for an app that has several.
1786 - // `Over` as well as `Screen`, because a screen that is
1787 - // an overlay everywhere else is the whole of this window
1788 - // when the window is what the app opened for it. "Over"
1789 - // says what a screen is drawn on top of, and a modal
1790 - // given a window of its own is drawn on top of the app.
1791 - //
1792 - // Missing until 2026-08-22, and it did not matter while
1793 - // every described window was a `Screen`: the first flip
1794 - // pointed a window at `/library/loose-files`, which is
1795 - // an `Over`, and the window drew the outcome's `Debug`
1796 - // rendering instead of the screen.
1797 - quasi_router::Outcome::Screen(screen)
1798 - | quasi_router::Outcome::Over(screen) => {
1799 - none.insert(Runtime::new(screen).with_chrome(super::help::chrome()))
1800 - }
1801 - other => {
1802 - ui.label(format!("the home address answered {other:?}"));
1803 - return;
1804 - }
1805 - },
1806 - Err(message) => {
1807 - ui.label(message);
1808 - return;
1809 - }
1810 - },
1811 - };
1812 -
1813 - // Before the drawing, so the frame draws what is true now rather
1814 - // than showing the previous answer for one more frame. `reload`
1815 - // re-asks the address the screen came from, and the runtime keeps
1816 - // what the user has typed and ticked across it.
1817 - //
1818 - // **Never while an overlay is open.** `reload` re-asks the address
1819 - // the screen came from, and an overlay is not a place, so that
1820 - // address is the screen *underneath* -- which answers
1821 - // `Outcome::Screen`, which clears the layer stack. An unconditional
1822 - // refresh would take the modal down on the frame after it opened.
1823 - // See `bulk`'s header, finding 2.
1824 - if refresh && !runtime.overlaid() {
1825 - let step = runtime.reload();
1826 - perform(runtime, ui, host, step);
1827 - }
1828 -
1829 - // A screen that says it is live is re-asked on the renderer's own
1830 - // cadence, which is `quasi_immediate::CADENCE` and is paced by the
1831 - // runtime rather than by anything here. This is the whole of what
1832 - // the sync panel's finding asked for: its state moves when an OAuth
1833 - // callback lands in another process, and until now nothing but an
1834 - // intent or a per-frame reload would notice.
1835 - //
1836 - // Under the same overlay guard as the refresh above, and for the
1837 - // same reason: a live screen's address is the screen underneath an
1838 - // open modal, and asking for it would take the modal down.
1839 - if !runtime.overlaid() {
1840 - for request in runtime.refreshes() {
1841 - call(runtime, host, request);
1842 - }
1843 - }
1844 -
1845 - let step = runtime.show(ui, &immediate);
1846 - perform(runtime, ui, host, step);
1847 -
1848 - // One drain for every described window, rather than one per
1849 - // `draw_*`: a file is produced by a route and a route is reachable
1850 - // from all of them, so the host answer belongs where the runtime is
1851 - // driven. Last in the frame because `perform` above is what may have
1852 - // just produced one.
1853 - hand_over(runtime, host.state);
1854 - });
1861 + .show(ctx, |ui| drive(ui, runtime, host, home, refresh));
1855 1862
1856 1863 !open
1857 1864 }
1858 1865
1866 + /// One described screen, filling whatever it is given.
1867 + ///
1868 + /// The full-screen modes take this rather than [`window`]: the review queue, the
1869 + /// import flow and the export flow are drawn into the app's own pane and have no
1870 + /// frame of their own to close.
1871 + fn inline(
1872 + ui: &mut egui::Ui,
1873 + runtime: &mut Option<Runtime>,
1874 + host: &Host<'_>,
1875 + home: &str,
1876 + refresh: bool,
1877 + ) {
1878 + drive(ui, runtime, host, home, refresh);
1879 + }
1880 +
1859 1881 /// Do what the runtime asked for.
1860 1882 fn perform(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, step: Step) {
1861 1883 match step {
@@ -563,6 +563,20 @@
563 563 self.gaining(label)
564 564 }
565 565
566 + /// A row the description makes pressable and `quasi-immediate` draws mute.
567 + ///
568 + /// `node::row` claims the strip with a bare `ui.interact`, which registers
569 + /// no `WidgetInfo`, so no node reaches the widget tree at all. The press
570 + /// works under a mouse and does not exist for anything else: a keyboard or
571 + /// screen-reader user cannot open a row.
572 + ///
573 + /// Worse than the two label findings beside it, which mis-name a control
574 + /// that is at least there. Filed against quasicoherent.
575 + #[must_use]
576 + pub(super) fn mute_row(self, label: &str) -> Self {
577 + self.gaining(label)
578 + }
579 +
566 580 /// Assert the two sides offer the same thing, panicking with a diff if not.
567 581 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
568 582 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
@@ -876,3 +890,53 @@
876 890 .as_i64()
877 891 }
878 892 }
893 +
894 + /// A review queue with one tag in it, for the screen that shows one.
895 + fn with_a_review_queue(state: &mut crate::state::BrowserState) {
896 + use crate::state::{ReviewCandidate, ReviewGroup, ReviewQueue};
897 +
898 + state.classifier.review = Some(ReviewQueue {
899 + groups: vec![ReviewGroup {
900 + tag: "instrument.drum.kick".to_owned(),
901 + candidates: vec![
902 + ReviewCandidate {
903 + hash: "aaa111".to_owned(),
904 + name: Some("kick.wav".to_owned()),
905 + score: 0.95,
906 + confident: true,
907 + accepted: false,
908 + },
909 + ReviewCandidate {
910 + hash: "bbb222".to_owned(),
911 + name: Some("snare.wav".to_owned()),
912 + score: 0.42,
913 + confident: false,
914 + accepted: false,
915 + },
916 + ],
917 + names_loaded: true,
918 + }],
919 + samples_considered: 2,
920 + samples_with_suggestions: 2,
921 + });
922 + state.open_review_screen();
923 + }
924 +
925 + #[test]
926 + fn the_tag_queue_serves_what_it_describes() {
927 + let (mut state, _dir) = fixture();
928 + with_a_review_queue(&mut state);
929 +
930 + let described = described(&super::panel::described_screen(&state, "/review"));
931 + let drawn = shipped(|ui| {
932 + super::panel::draw_queue(ui, &mut state);
933 + });
934 +
935 + described.addresses_resolve();
936 + // No `in_a_window`: the queue is a full-screen mode drawn into the app's own
937 + // pane, which is what the shipped screen was, so there is no frame around it
938 + // to discount.
939 + Parity::strict()
940 + .mute_row("instrument.drum.kick")
941 + .assert(&described, &drawn);
942 + }
@@ -83,6 +83,19 @@
83 83 /// The band under it.
84 84 const FOOT: &str = "review-foot";
85 85
86 + /// Candidate rows drawn for the selected group.
87 + ///
88 + /// A drawing and name-resolution budget, not a cap on the group: every act on
89 + /// this screen acts on all of it. Names are one backend call each, so resolving
90 + /// a 44,000-row group to fill two screens would stall the frame.
91 + ///
92 + /// Lived in `ui::review_library` until that module was deleted (2026-08-22), and
93 + /// came here rather than going with it: the described screen is what reads it
94 + /// now, and `BrowserState::ensure_review_names` takes it as an argument, so it
95 + /// is a fact about how much of a group is worth resolving rather than about any
96 + /// one renderer.
97 + pub const RENDER_ROWS: usize = 200;
98 +
86 99 /// Register the queue's routes.
87 100 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
88 101 router
@@ -3340,8 +3340,8 @@
3340 3340 /// The library review queue: what the suggest-only path does when the user acts.
3341 3341 mod review_queue {
3342 3342 use super::*;
3343 + use crate::quasi::queue::RENDER_ROWS;
3343 3344 use crate::state::{ImportMode, ReviewSelection};
3344 - use crate::ui::review_library::RENDER_ROWS;
3345 3345
3346 3346 /// A queue with one group of three, the first two confident.
3347 3347 fn seeded(state: &mut BrowserState) {
@@ -15,7 +15,6 @@
15 15 pub mod instrument_panel;
16 16 pub mod layout_strip;
17 17 pub mod overlays;
18 - pub mod review_library;
19 18 pub mod settings_panel;
20 19 pub mod sidebar;
21 20 pub mod sync_panel;
@@ -1,384 +1,0 @@
1 - //! The library-wide tag review screen.
2 - //!
3 - //! What the k-NN would tag, grouped by tag, accepted in bulk. Nothing it proposes
4 - //! reaches a library until someone here says so.
5 - //!
6 - //! The queue is fed by the exemplar index, which is the user's own labels. It was
7 - //! also fed by a bundled `.afcl` layer until 2026-08-08, when that was retired for
8 - //! contributing nothing past a user's first ~88 labels (wiki `af-likeness-web`).
9 - //! The screen outlived it because the two were never the same thing: the measured
10 - //! decay was the BUNDLED layer's share fading as the user tagged, and what it faded
11 - //! into was this queue running on their own labels, which is the part that works.
12 - //!
13 - //! # Grouped by tag, and that is the whole design
14 - //!
15 - //! "Are these 340 all kicks?" is a question one scan of a list answers. The same
16 - //! 340 rows one sample at a time is 340 questions nobody finishes, and a screen
17 - //! nobody finishes pushes the user to an undifferentiated accept-all, which is
18 - //! auto-apply with extra steps. So the tag is the unit of navigation (left panel)
19 - //! and of action (the buttons), and individual samples exist to be opted out of
20 - //! rather than opted into.
21 - //!
22 - //! Nothing arrives ticked, for the same reason: a screen that opens with 340
23 - //! boxes already checked is auto-apply wearing a checkbox.
24 - //!
25 - //! # Two windows, both deliberate
26 - //!
27 - //! Groups are complete in state, because "Accept all 44,000" has to mean 44,000.
28 - //! Only the drawing is bounded ([`RENDER_ROWS`]), and so is name resolution,
29 - //! which costs a backend call per row. The screen says when it is showing a
30 - //! window, so a partial list never reads as the whole group.
31 -
32 - use egui;
33 -
34 - use super::{theme, widgets};
35 - use crate::state::{BrowserState, ImportMode, ReviewSelection};
36 -
37 - /// Candidate rows drawn for the selected group.
38 - ///
39 - /// A drawing and name-resolution budget, not a cap on the group: every button on
40 - /// this screen acts on all of it. Names are one backend call each, so resolving a
41 - /// 44,000-row group to fill two screens would stall the frame.
42 - pub const RENDER_ROWS: usize = 200;
43 -
44 - pub fn draw_review_library(ui: &mut egui::Ui, state: &mut BrowserState) {
45 - if !matches!(
46 - state.import_wf.import_mode,
47 - ImportMode::ReviewLibrary { .. }
48 - ) {
49 - return;
50 - }
51 - // The queue can empty under the screen: accepting the last group removes it.
52 - // Leaving rather than drawing an empty shell, so "I finished" and "there was
53 - // never anything" do not look the same.
54 - if state
55 - .classifier
56 - .review
57 - .as_ref()
58 - .is_none_or(|q| q.groups.is_empty())
59 - {
60 - state.close_review_screen();
61 - state.status = "Review queue cleared.".to_string();
62 - return;
63 - }
64 -
65 - let ctx = ui.ctx().clone();
66 - let selected = state.review_selected();
67 - state.set_review_selected(selected);
68 -
69 - draw_header(ui, state);
70 - draw_footer(ui, state);
71 - draw_group_list(ui, state, selected, &ctx);
72 - draw_candidates(ui, state, selected);
73 - }
74 -
75 - /// Title, what the pass found, and the one queue-wide action.
76 - fn draw_header(ui: &mut egui::Ui, state: &mut BrowserState) {
77 - let (groups, total, considered, with_suggestions) = {
78 - let q = state.classifier.review.as_ref().expect("checked by caller");
79 - (
80 - q.groups.len(),
81 - q.groups.iter().map(|g| g.candidates.len()).sum::<usize>(),
82 - q.samples_considered,
83 - q.samples_with_suggestions,
84 - )
85 - };
86 - let confident = state.review_confident_total();
87 - let mut accept_confident = false;
88 -
89 - egui::Panel::top("review_library_header").show(ui, |ui| {
90 - ui.horizontal(|ui| {
91 - ui.heading("Review Tags");
92 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
93 - // The queue-wide gesture, and the closest thing left to the
94 - // auto-apply this layer no longer does. Still a decision made
95 - // with the count on screen.
96 - if confident > 0
97 - && ui
98 - .button(format!("Accept {confident} confident"))
99 - .on_hover_text(
100 - "Across every tag: only suggestions above each tag's auto threshold",
101 - )
102 - .clicked()
103 - {
104 - accept_confident = true;
105 - }
106 - });
107 - });
108 - ui.label(
109 - egui::RichText::new(format!(
110 - "{total} suggestion{} across {groups} tag{} \u{00b7} \
111 - {with_suggestions} of {considered} sample{}",
112 - if total == 1 { "" } else { "s" },
113 - if groups == 1 { "" } else { "s" },
114 - if considered == 1 { "" } else { "s" },
115 - ))
116 - .small()
117 - .color(theme::content_muted()),
118 - );
119 - ui.label(
120 - egui::RichText::new("Nothing is applied until you accept it.")
121 - .small()
122 - .color(theme::content_muted()),
123 - );
124 - ui.add_space(theme::space::hair());
125 - });
126 -
127 - if accept_confident {
128 - state.accept_all_confident();
129 - }
130 - }
131 -
132 - fn draw_footer(ui: &mut egui::Ui, state: &mut BrowserState) {
133 - let msg = state.classifier.last_review_accept.clone();
134 - let mut close = false;
135 - let mut rescan = false;
136 -
137 - egui::Panel::bottom("review_library_footer").show(ui, |ui| {
138 - ui.add_space(theme::space::bound());
139 - ui.horizontal(|ui| {
140 - if ui
141 - .button("Close")
142 - .on_hover_text("Keeps the queue: re-open it from Settings > Auto-Tagging")
143 - .clicked()
144 - {
145 - close = true;
146 - }
147 - if ui
148 - .add_enabled(
149 - state.classifier.busy.is_none(),
150 - egui::Button::new("Rescan library"),
151 - )
152 - .on_hover_text("Run the pass again. Writes nothing.")
153 - .clicked()
154 - {
155 - rescan = true;
156 - }
157 - if let Some(msg) = &msg {
158 - ui.label(
159 - egui::RichText::new(msg)
160 - .small()
161 - .color(theme::content_muted()),
162 - );
163 - }
164 - });
165 - ui.add_space(theme::space::hair());
166 - });
167 -
168 - if close {
169 - state.close_review_screen();
170 - }
171 - if rescan {
172 - state.classifier_review_library();
173 - }
174 - }
175 -
176 - /// The tag list: the screen's unit of navigation.
177 - fn draw_group_list(
178 - ui: &mut egui::Ui,
179 - state: &mut BrowserState,
180 - selected: usize,
181 - ctx: &egui::Context,
182 - ) {
183 - // Up/down walk the tags, matching the import review screen. Suppressed while
184 - // a text field owns focus so typing is never hijacked.
185 - let nav: i32 = if ctx.memory(|m| m.focused().is_some()) {
186 - 0
187 - } else {
188 - let up = ctx.input(|i| i.key_pressed(egui::Key::ArrowUp));
189 - let down = ctx.input(|i| i.key_pressed(egui::Key::ArrowDown));
190 - match (up, down) {
191 - (true, false) => -1,
192 - (false, true) => 1,
193 - _ => 0,
194 - }
195 - };
196 -
197 - let mut clicked: Option<usize> = None;
198 - egui::Panel::left("review_library_tags")
199 - .resizable(true)
200 - .default_size(240.0)
201 - .show(ui, |ui| {
202 - widgets::subsection_label(ui, "Tags");
203 - egui::ScrollArea::vertical().show(ui, |ui| {
204 - let Some(queue) = state.classifier.review.as_ref() else {
205 - return;
206 - };
207 - for (i, group) in queue.groups.iter().enumerate() {
208 - let is_selected = i == selected;
209 - let confident = group.confident();
210 - let response = ui.selectable_label(
211 - is_selected,
212 - egui::RichText::new(format!(
213 - "{}\n{} suggestion{}{}",
214 - group.tag,
215 - group.candidates.len(),
216 - if group.candidates.len() == 1 { "" } else { "s" },
217 - if confident > 0 {
218 - format!(", {confident} confident")
219 - } else {
220 - String::new()
221 - }
222 - ))
223 - .small(),
224 - );
225 - if response.clicked() {
226 - clicked = Some(i);
227 - }
228 - }
229 - });
230 - });
231 -
232 - let count = state
233 - .classifier
234 - .review
235 - .as_ref()
236 - .map_or(0, |q| q.groups.len());
237 - let target = clicked.unwrap_or_else(|| {
238 - let next = i64::try_from(selected).unwrap_or(0) + i64::from(nav);
239 - usize::try_from(next.clamp(0, i64::try_from(count.saturating_sub(1)).unwrap_or(0)))
240 - .unwrap_or(0)
241 - });
242 - if target != selected || clicked.is_some() {
243 - state.set_review_selected(target);
244 - }
245 - state.ensure_review_names(target, RENDER_ROWS);
246 - }
247 -
248 - /// The selected group: its actions, then its samples.
249 - fn draw_candidates(ui: &mut egui::Ui, state: &mut BrowserState, selected: usize) {
250 - let mut accept: Option<ReviewSelection> = None;
251 - let mut dismiss = false;
252 - let mut toggle: Option<usize> = None;
253 - let mut set_all_checked: Option<bool> = None;
254 -
255 - egui::CentralPanel::default().show(ui, |ui| {
256 - let Some(group) = state
257 - .classifier
258 - .review
259 - .as_ref()
260 - .and_then(|q| q.groups.get(selected))
261 - else {
262 - return;
263 - };
264 - let count = group.candidates.len();
265 - let confident = group.confident();
266 - let checked = group.checked();
267 -
268 - ui.horizontal(|ui| {
269 - ui.heading(&group.tag);
270 - });
271 - ui.label(
272 - egui::RichText::new(format!(
273 - "{count} sample{} would get this tag.",
274 - if count == 1 { "" } else { "s" }
275 - ))
276 - .small()
277 - .color(theme::content_muted()),
278 - );
279 - ui.add_space(theme::space::bound());
280 -
281 - ui.horizontal(|ui| {
282 - if widgets::primary_button(ui, &format!("Accept all {count}")).clicked() {
283 - accept = Some(ReviewSelection::All);
284 - }
285 - // Only worth offering when it is a real subset; otherwise it is a
286 - // second button that does what the first one does.
287 - if confident > 0
288 - && confident < count
289 - && ui
290 - .button(format!("Accept {confident} confident"))
291 - .on_hover_text("Only those above this tag's auto threshold")
292 - .clicked()
293 - {
294 - accept = Some(ReviewSelection::Confident);
295 - }
296 - if checked > 0 && ui.button(format!("Accept {checked} checked")).clicked() {
297 - accept = Some(ReviewSelection::Checked);
298 - }
299 - if widgets::danger_small_button(ui, "Dismiss tag").clicked() {
300 - dismiss = true;
301 - }
302 - });
303 -
304 - ui.add_space(theme::space::bound());
305 - ui.horizontal(|ui| {
306 - if ui.small_button("Check all").clicked() {
307 - set_all_checked = Some(true);
308 - }
309 - if checked > 0 && ui.small_button("Uncheck all").clicked() {
310 - set_all_checked = Some(false);
311 - }
312 - if count > RENDER_ROWS {
313 - ui.label(
314 - egui::RichText::new(format!(
315 - "Showing the {RENDER_ROWS} strongest. The buttons above apply to all \
316 - {count}.",
317 - ))
318 - .small()
319 - .color(theme::content_muted()),
320 - );
321 - }
322 - });
323 - ui.add_space(theme::space::bound());
324 -
325 - egui::ScrollArea::vertical()
326 - .id_salt(("review-library-candidates", selected))
327 - .show(ui, |ui| {
328 - for (i, c) in group.candidates.iter().take(RENDER_ROWS).enumerate() {
329 - ui.horizontal(|ui| {
330 - let mut ticked = c.accepted;
331 - if ui.checkbox(&mut ticked, "").changed() {
332 - toggle = Some(i);
333 - }
334 - ui.label(egui::RichText::new(c.name.as_deref().unwrap_or(&c.hash)).small());
335 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
336 - // Confident rows read at full contrast, review-band
337 - // rows muted, so the band is visible without a second
338 - // column of words.
339 - ui.label(
340 - egui::RichText::new(format!("{:.0}%", c.score * 100.0))
341 - .small()
342 - .color(if c.confident {
343 - theme::content()
344 - } else {
345 - theme::content_muted()
346 - }),
347 - );
348 - });
349 - });
350 - }
351 - });
352 - });
353 -
354 - if let Some(all) = set_all_checked
355 - && let Some(group) = state
356 - .classifier
357 - .review
358 - .as_mut()
359 - .and_then(|q| q.groups.get_mut(selected))
360 - {
361 - // Only the drawn rows: ticking 44,000 invisible boxes so that "Accept
362 - // checked" silently means "accept everything" would defeat the point of
363 - // having a separate all-vs-checked distinction.
364 - for c in group.candidates.iter_mut().take(RENDER_ROWS) {
365 - c.accepted = all;
366 - }
367 - }
368 - if let Some(i) = toggle
369 - && let Some(c) = state
370 - .classifier
371 - .review
372 - .as_mut()
373 - .and_then(|q| q.groups.get_mut(selected))
374 - .and_then(|g| g.candidates.get_mut(i))
375 - {
376 - c.accepted = !c.accepted;
377 - }
378 - if let Some(which) = accept {
379 - state.accept_review(selected, which);
380 - }
381 - if dismiss {
382 - state.dismiss_review_group(selected);
383 - }
384 - }