max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | - | } |