Skip to main content

max / audiofiles

Describe the library tag queue, and record why dialog.rs is not a screen `ui/review_library.rs` (384) at `/review`. The design its own header argues for turns out to be described facts rather than drawing decisions: the tag is the unit of navigation and of action, the three accept scopes are offered only where each is a real subset, and nothing arrives ticked. `ui/dialog.rs` (267) is not described, and the reason is recorded in `quasi/mod.rs` beside `draw_confirm_dialog`'s. It draws nothing: four picker kinds, a worker thread and a handler applied frames later. It is also the sharpest measurement this layer has of `quasi:vocabulary:host-save-location` -- eight consumers across the app, every one of them reaching this file, so a native picker is a whole subsystem the description cannot name rather than an oversight in one screen. It is why the second half of that gap exists too: the answer arrives long after the route that asked is finished. The candidate list carries a `Rest`, and it is the first one here that should. `files` refused to describe its own windowing because it holds every row it describes; this one resolves a display name per candidate through a backend call and stops at 200, so the rows past it were never fetched. That is `Rest`'s own definition. The inexactness -- there is no next page, by design -- is a paragraph rather than a finding, since this is its only site. Sixth consumer of `quasi:vocabulary:disabled-reason` (Rescan while a pass runs) and a second for the key-bound list walk that `Chrome` cannot express. 566 tests green with --features quasi, 9 of them new.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 21:06 UTC
Signed with PGP, not checked
Commit: e794b0fb216b454b0f5be99d3ea5706907be55ad
Parent: 9f0ae68
5 files changed, +1180 insertions, -13 deletions
@@ -252,6 +252,17 @@
252 252 crate::quasi::panel::draw_sweep(ctx, state);
253 253 }
254 254
255 + // The described tag queue, beside the shipped review screen. Same terms as
256 + // the two flows: the shipped side takes over the central pane rather than
257 + // being a window, so this opens when that screen does.
258 + #[cfg(feature = "quasi")]
259 + if matches!(
260 + state.import_wf.import_mode,
261 + crate::state::ImportMode::ReviewLibrary { .. }
262 + ) {
263 + crate::quasi::panel::draw_queue(ctx, state);
264 + }
265 +
255 266 // Sync panel overlay
256 267 if state.sync.show_panel {
257 268 // The described one beside it, on the same toggle. `None` is the case
@@ -67,6 +67,29 @@
67 67 //! for it. [`sync`]'s Disconnect has done that since it landed. **Ten variants
68 68 //! replaced by two builder methods**, and the port's contribution is counting
69 69 //! them rather than writing anything.
70 + //!
71 + //! # `ui::dialog` is not a screen either, and it is the evidence for a finding
72 + //!
73 + //! 267 lines, four picker kinds — pick a folder, pick a file, pick several, save
74 + //! — a worker thread, a `Send` handler applied on the GUI thread a frame or more
75 + //! later, and a note about macOS run loops. **It draws nothing.** There is no
76 + //! screen here to describe and no capability to narrow: it is the mechanism a
77 + //! host uses to ask the operating system a question, which is the definition of
78 + //! a host concern.
79 + //!
80 + //! Recorded rather than skipped, because it is the sharpest measurement this
81 + //! layer has of `quasi:vocabulary:host-save-location`. The gap has eight
82 + //! consumers across the app — the export destination, Export Theme, Locate
83 + //! missing files, and the import flow's four doors — and every one of them
84 + //! reaches this file. What the count says is that a native picker is not an
85 + //! oversight in one screen but a whole subsystem the description cannot name,
86 + //! and that `FieldKind::File` covering "pick a file to submit" answers the one
87 + //! shape of it nobody here uses.
88 + //!
89 + //! There is a second half, filed with the import flow: a route that hands off to
90 + //! the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here
91 + //! changed". `dialog.rs` is why — the answer arrives frames later, on a thread,
92 + //! through a closure, and the route that asked is long finished.
70 93
71 94 // Handlers take their request by value because `quasi_router::Handler` is a
72 95 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
@@ -85,6 +108,7 @@
85 108 pub mod library;
86 109 pub mod naming;
87 110 pub mod panel;
111 + pub mod queue;
88 112 pub mod settings;
89 113 pub mod shell;
90 114 pub mod sync;
@@ -680,6 +704,22 @@
680 704 Conform,
681 705 /// Trim silence off everything chosen.
682 706 TrimSilence,
707 + /// Open this tag in the review queue.
708 + ReadGroup(usize),
709 + /// Tick or untick one candidate of the open tag.
710 + TickCandidate(usize),
711 + /// Tick or untick every candidate the screen is showing.
712 + TickShown(bool),
713 + /// Apply this much of the open tag.
714 + AcceptGroup(Scope),
715 + /// Apply every confident suggestion under every tag.
716 + AcceptConfident,
717 + /// Throw the open tag away.
718 + DismissGroup,
719 + /// Run the library pass again.
720 + Rescan,
721 + /// Put the review screen away, keeping the queue.
722 + CloseReview,
683 723 /// Open the export flow on whatever is selected.
684 724 BeginExport,
685 725 /// Put the loose-files warning away without acting.
@@ -3764,6 +3804,237 @@
3764 3804 }
3765 3805 }
3766 3806
3807 + /// The library-wide tag queue, as much of it as a described screen needs.
3808 + ///
3809 + /// One struct where the flow is an enum, and [`Forging`]'s reason again: the
3810 + /// three sections of this screen are all live at once and nothing here is a
3811 + /// state a reader arrived at. `rescanning` is a field for exactly the reason
3812 + /// `busy` is one.
3813 + #[derive(Debug, Clone, PartialEq)]
3814 + pub struct Queued {
3815 + /// Every tag with something waiting under it.
3816 + pub groups: Vec<Group>,
3817 + /// Which one is open, as an index into `groups`.
3818 + pub at: usize,
3819 + /// How many samples the pass looked at.
3820 + pub considered: usize,
3821 + /// How many of them it had something to say about.
3822 + pub suggested: usize,
3823 + /// How many suggestions across the whole queue clear their tag's threshold.
3824 + pub confident: usize,
3825 + /// Whether a pass is running.
3826 + pub rescanning: bool,
3827 + /// What the last accept did, if it has said anything.
3828 + pub said: Option<String>,
3829 + /// The open group's candidates, as far as their names have been resolved.
3830 + ///
3831 + /// A window, and a real one rather than a renderer's: see [`Candidate`].
3832 + pub shown: Vec<Candidate>,
3833 + }
3834 +
3835 + /// One tag, and how much is waiting under it.
3836 + #[derive(Debug, Clone, PartialEq, Eq)]
3837 + pub struct Group {
3838 + /// The tag itself.
3839 + pub tag: String,
3840 + /// How many samples would get it.
3841 + pub candidates: usize,
3842 + /// How many of those clear its auto threshold.
3843 + pub confident: usize,
3844 + /// How many are ticked.
3845 + pub checked: usize,
3846 + }
3847 +
3848 + /// One sample that would get the open tag.
3849 + ///
3850 + /// Only ever the strongest few hundred, and that is a fact about the data rather
3851 + /// than about the drawing. `ReviewGroup::names_loaded` resolves a display name
3852 + /// per candidate through one backend call each, so a 44,000-row group is
3853 + /// resolved to the window and no further — the rows past it have no name to
3854 + /// carry. Every control on the screen still acts on the whole group.
3855 + ///
3856 + /// The distinction [`Files`] draws in the other direction: the file list holds
3857 + /// every row it describes, so windowing there is a renderer's performance
3858 + /// technique and `more` is `None`.
3859 + #[derive(Debug, Clone, PartialEq)]
3860 + pub struct Candidate {
3861 + /// What the sample is called, or its hash until the name is resolved.
3862 + pub name: String,
3863 + /// How strongly it matches, from zero to one.
3864 + pub score: f64,
3865 + /// Whether it clears the tag's auto threshold.
3866 + pub confident: bool,
3867 + /// Whether it is ticked.
3868 + pub accepted: bool,
3869 + }
3870 +
3871 + /// How much of a group an accept applies to.
3872 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3873 + pub enum Scope {
3874 + /// Every candidate under the tag.
3875 + All,
3876 + /// Only those above its auto threshold.
3877 + Confident,
3878 + /// Only the ticked ones.
3879 + Checked,
3880 + }
3881 +
3882 + impl Scope {
3883 + /// The name a described address is built from.
3884 + #[must_use]
3885 + pub const fn as_str(self) -> &'static str {
3886 + match self {
3887 + Self::All => "all",
3888 + Self::Confident => "confident",
3889 + Self::Checked => "checked",
3890 + }
3891 + }
3892 +
3893 + /// The scope that name means, if it means one.
3894 + #[must_use]
3895 + pub fn from_key(name: &str) -> Option<Self> {
3896 + match name {
3897 + "all" => Some(Self::All),
3898 + "confident" => Some(Self::Confident),
3899 + "checked" => Some(Self::Checked),
3900 + _ => None,
3901 + }
3902 + }
3903 + }
3904 +
3905 + /// The tag queue, as much of it as a described screen needs.
3906 + ///
3907 + /// The fifteenth narrow trait, and the second over `classifier` rather than over
3908 + /// `import_wf` — which one group is open is `ImportMode::ReviewLibrary`'s
3909 + /// `selected`, and the queue itself is `classifier.review`. Every write is an
3910 + /// [`Intent`] because both of those are the app's own state.
3911 + pub trait Queue {
3912 + /// The queue, if there is one worth reading.
3913 + fn queued(&self) -> Option<Queued>;
3914 +
3915 + /// Open this tag.
3916 + fn read(&self, at: usize);
3917 +
3918 + /// Tick or untick one candidate of the open tag.
3919 + fn tick(&self, at: usize);
3920 +
3921 + /// Tick or untick every candidate the screen is showing.
3922 + fn tick_shown(&self, ticked: bool);
3923 +
3924 + /// Apply this much of the open tag.
3925 + fn accept(&self, scope: Scope);
3926 +
3927 + /// Apply every confident suggestion under every tag.
3928 + fn accept_confident(&self);
3929 +
3930 + /// Throw the open tag away without applying any of it.
3931 + fn dismiss(&self);
3932 +
3933 + /// Run the pass again.
3934 + fn rescan(&self);
3935 +
3936 + /// Put the screen away, keeping the queue.
3937 + fn close(&self);
3938 + }
3939 +
3940 + /// The app's tag queue, as the narrow thing the described screen borrows.
3941 + pub struct FromQueue<'a> {
3942 + /// What the last pass found.
3943 + pub state: &'a crate::state::BrowserState,
3944 + /// What the described screen asked for, applied after the frame.
3945 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
3946 + }
3947 +
3948 + impl Queue for FromQueue<'_> {
3949 + fn queued(&self) -> Option<Queued> {
3950 + let queue = self.state.classifier.review.as_ref()?;
3951 + // An empty queue is not a queue. The shipped screen leaves rather than
3952 + // drawing an empty shell, on the grounds that "I finished" and "there
3953 + // was never anything" should not look the same; the described side
3954 + // cannot leave, so it refuses the address instead.
3955 + if queue.groups.is_empty() {
3956 + return None;
3957 + }
3958 +
3959 + let at = self.state.review_selected();
3960 + Some(Queued {
3961 + groups: queue
3962 + .groups
3963 + .iter()
3964 + .map(|group| Group {
3965 + tag: group.tag.clone(),
3966 + candidates: group.candidates.len(),
3967 + confident: group.confident(),
3968 + checked: group.checked(),
3969 + })
3970 + .collect(),
3971 + at,
3972 + considered: queue.samples_considered,
3973 + suggested: queue.samples_with_suggestions,
3974 + confident: self.state.review_confident_total(),
3975 + rescanning: self.state.classifier.busy.is_some(),
3976 + said: self.state.classifier.last_review_accept.clone(),
3977 + shown: queue.groups.get(at).map_or_else(Vec::new, |group| {
3978 + group
3979 + .candidates
3980 + .iter()
3981 + .take(crate::ui::review_library::RENDER_ROWS)
3982 + .map(|candidate| Candidate {
3983 + // The hash stands in until the name is resolved, which
3984 + // is the shipped row's own fallback.
3985 + name: candidate
3986 + .name
3987 + .clone()
3988 + .unwrap_or_else(|| candidate.hash.clone()),
3989 + score: candidate.score,
3990 + confident: candidate.confident,
3991 + accepted: candidate.accepted,
3992 + })
3993 + .collect()
3994 + }),
3995 + })
3996 + }
3997 +
3998 + fn read(&self, at: usize) {
3999 + self.push(Intent::ReadGroup(at));
4000 + }
4001 +
4002 + fn tick(&self, at: usize) {
4003 + self.push(Intent::TickCandidate(at));
4004 + }
4005 +
4006 + fn tick_shown(&self, ticked: bool) {
4007 + self.push(Intent::TickShown(ticked));
4008 + }
4009 +
4010 + fn accept(&self, scope: Scope) {
4011 + self.push(Intent::AcceptGroup(scope));
4012 + }
4013 +
4014 + fn accept_confident(&self) {
4015 + self.push(Intent::AcceptConfident);
4016 + }
4017 +
4018 + fn dismiss(&self) {
4019 + self.push(Intent::DismissGroup);
4020 + }
4021 +
4022 + fn rescan(&self) {
4023 + self.push(Intent::Rescan);
4024 + }
4025 +
4026 + fn close(&self) {
4027 + self.push(Intent::CloseReview);
4028 + }
4029 + }
4030 +
4031 + impl FromQueue<'_> {
4032 + /// Record what the described screen asked for.
4033 + fn push(&self, intent: Intent) {
4034 + self.intents.borrow_mut().push(intent);
4035 + }
4036 + }
4037 +
3767 4038 /// The sample in the forge, and everything the maker surface asks about it.
3768 4039 ///
3769 4040 /// One struct where [`Stage`] is an enum, and the difference is the screen: the
@@ -4293,6 +4564,8 @@
4293 4564 pub editor: &'a dyn Edit,
4294 4565 /// The sample in the forge, for the maker surface.
4295 4566 pub forge: &'a dyn Forge,
4567 + /// The library-wide tag queue, for the review screen.
4568 + pub queue: &'a dyn Queue,
4296 4569 /// The themes on offer, resolved by the host at startup.
4297 4570 pub themes: &'a [ThemeChoice],
4298 4571 }
@@ -4303,10 +4576,10 @@
4303 4576 /// cost is nothing, and building it fresh is what lets the state borrow.
4304 4577 #[must_use]
4305 4578 pub fn router<'a>() -> Router<Panels<'a>> {
4306 - forge::routes(edit::routes(integrity::routes(importing::routes(
4307 - naming::routes(toolbar::routes(library::routes(shell::routes(
4308 - help::routes(bulk::routes(detail::routes(export::routes(files::routes(
4309 - sync::routes(settings::routes(Router::new())),
4579 + queue::routes(forge::routes(edit::routes(integrity::routes(
4580 + importing::routes(naming::routes(toolbar::routes(library::routes(
4581 + shell::routes(help::routes(bulk::routes(detail::routes(export::routes(
4582 + files::routes(sync::routes(settings::routes(Router::new()))),
4310 4583 ))))),
4311 4584 )))),
4312 4585 ))))
@@ -34,8 +34,8 @@
34 34
35 35 use super::{
36 36 FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromForge, FromImport,
37 - FromIntegrity, FromLibrary, FromNaming, FromSelection, FromSyncManager, FromWindow, Intent,
38 - Panels, Setting, Sync, ThemeChoice, Unconfigured,
37 + FromIntegrity, FromLibrary, FromNaming, FromQueue, FromSelection, FromSyncManager, FromWindow,
38 + Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
39 39 };
40 40 use crate::state::BrowserState;
41 41 use crate::ui::theme;
@@ -56,6 +56,7 @@
56 56 edit: Option<Runtime>,
57 57 forge: Option<Runtime>,
58 58 import: Option<Runtime>,
59 + queue: Option<Runtime>,
59 60 sweep: Option<Runtime>,
60 61 /// Whether the described main window is open.
61 62 pub show_shell: bool,
@@ -360,6 +361,34 @@
360 361 }
361 362 }
362 363
364 + /// Draw the described tag queue, and act on whatever was pressed.
365 + ///
366 + /// **Refreshed unconditionally**, and the reason is the rescan: the pass runs on
367 + /// a worker and the queue it builds arrives with nothing pressed.
368 + pub fn draw_queue(ctx: &egui::Context, state: &mut BrowserState) {
369 + let intents = RefCell::new(Vec::new());
370 + let mut runtime = state.described.queue.take();
371 + let host = Host {
372 + state,
373 + sync: None,
374 + themes: themes(),
375 + intents: &intents,
376 + };
377 + let closed = window(
378 + ctx,
379 + "Review Tags (described)",
380 + &mut runtime,
381 + &host,
382 + "/review",
383 + true,
384 + );
385 + state.described.queue = runtime;
386 + apply(ctx, state, intents.into_inner());
387 + if closed {
388 + state.described.queue = None;
389 + }
390 + }
391 +
363 392 /// Draw the described sweep, and act on whatever was pressed.
364 393 ///
365 394 /// Its own window rather than a stage of the import flow's, because it is not
@@ -1006,6 +1035,64 @@
1006 1035 let threshold = state.forge.trim_threshold_db;
1007 1036 state.batch_trim_silence(threshold);
1008 1037 }
1038 + // The tag queue. Opening a tag also resolves the names of the rows
1039 + // that will be drawn for it, which is a backend call per row and so
1040 + // is the host's: the shipped screen does it from inside the drawing
1041 + // and a route holding `&S` could not.
1042 + Intent::ReadGroup(at) => {
1043 + state.set_review_selected(at);
1044 + state.ensure_review_names(at, crate::ui::review_library::RENDER_ROWS);
1045 + }
1046 + Intent::TickCandidate(at) => {
1047 + let selected = state.review_selected();
1048 + if let Some(candidate) = state
1049 + .classifier
1050 + .review
1051 + .as_mut()
1052 + .and_then(|queue| queue.groups.get_mut(selected))
1053 + .and_then(|group| group.candidates.get_mut(at))
1054 + {
1055 + candidate.accepted = !candidate.accepted;
1056 + }
1057 + }
1058 + // Only the drawn rows, which is the shipped button's own bound: see
1059 + // `queue`'s note on why "Accept checked" must not quietly become
1060 + // "accept everything".
1061 + Intent::TickShown(ticked) => {
1062 + let selected = state.review_selected();
1063 + if let Some(group) = state
1064 + .classifier
1065 + .review
1066 + .as_mut()
1067 + .and_then(|queue| queue.groups.get_mut(selected))
1068 + {
1069 + for candidate in group
1070 + .candidates
1071 + .iter_mut()
1072 + .take(crate::ui::review_library::RENDER_ROWS)
1073 + {
1074 + candidate.accepted = ticked;
1075 + }
1076 + }
1077 + }
1078 + Intent::AcceptGroup(scope) => {
1079 + let selected = state.review_selected();
1080 + state.accept_review(
1081 + selected,
1082 + match scope {
1083 + super::Scope::All => crate::state::ReviewSelection::All,
1084 + super::Scope::Confident => crate::state::ReviewSelection::Confident,
1085 + super::Scope::Checked => crate::state::ReviewSelection::Checked,
1086 + },
1087 + );
1088 + }
1089 + Intent::AcceptConfident => state.accept_all_confident(),
1090 + Intent::DismissGroup => {
1091 + let selected = state.review_selected();
1092 + state.dismiss_review_group(selected);
1093 + }
1094 + Intent::Rescan => state.classifier_review_library(),
1095 + Intent::CloseReview => state.close_review_screen(),
1009 1096 Intent::BeginExport => state.start_export_flow(None),
1010 1097 // The host act with no described step. See `integrity`'s header:
1011 1098 // fourth consumer of `quasi:vocabulary:host-save-location`.
@@ -1514,6 +1601,7 @@
1514 1601 let integrity = FromIntegrity { state, intents };
1515 1602 let editor = FromEditor { state, intents };
1516 1603 let forge = FromForge { state, intents };
1604 + let queue = FromQueue { state, intents };
1517 1605 let panels = Panels {
1518 1606 config: &config,
1519 1607 sync,
@@ -1529,6 +1617,7 @@
1529 1617 integrity: &integrity,
1530 1618 editor: &editor,
1531 1619 forge: &forge,
1620 + queue: &queue,
1532 1621 themes,
1533 1622 };
1534 1623 super::router()
@@ -12,13 +12,14 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Analysed, Analysis, Bar, Bulk, Channels, Chop, Chosen, Collection, ColumnsShown, Config,
16 - Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure, Files,
17 - Filter, Focus, Folder, FolderTags, Forge, Forging, Format, Halted, Holding, Importing,
18 - Integrity, Knob, Library, Measure, Measures, Migrating, Naming, Order, Panel, Panels, Phase,
19 - Playing, Preflight, Pricing, ProfileChoice, Reviewed, Sample, Saying, Searching, Setting,
20 - Settings, Shared, Shell, Source, Spread, Stage, State, Status, Strategy, Subject, Subscription,
21 - Suggested, Suggestion, Sweep, Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router,
15 + Analysed, Analysis, Bar, Bulk, Candidate, Channels, Chop, Chosen, Collection, ColumnsShown,
16 + Config, Coverage, Crumb, Decision, Detail, Detailed, DeviceChoice, Editing, Export, Failure,
17 + Files, Filter, Focus, Folder, FolderTags, Forge, Forging, Format, Group, Halted, Holding,
18 + Importing, Integrity, Knob, Library, Measure, Measures, Migrating, Naming, Order, Panel,
19 + Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue, Queued, Reviewed, Sample,
20 + Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source, Spread, Stage, State,
21 + Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep, Sync, Tagged,
22 + ThemeChoice, Vault, VaultChoice, Where, router,
22 23 };
23 24
24 25 /// A config store in memory.
@@ -229,6 +230,7 @@
229 230 integrity: &Sound,
230 231 editor: &Unedited,
231 232 forge: &Unforged,
233 + queue: &Unqueued,
232 234 themes: &themes,
233 235 };
234 236 router().handle(&state, request)
@@ -300,6 +302,7 @@
300 302 integrity: &Sound,
301 303 editor: &Unedited,
302 304 forge: &Unforged,
305 + queue: &Unqueued,
303 306 themes: &themes,
304 307 };
305 308 router().handle(&state, request)
@@ -455,6 +458,7 @@
455 458 integrity: &Sound,
456 459 editor: &Unedited,
457 460 forge: &Unforged,
461 + queue: &Unqueued,
458 462 themes: &themes,
459 463 };
460 464 let response = router()
@@ -510,6 +514,7 @@
510 514 integrity: &Sound,
511 515 editor: &Unedited,
512 516 forge: &Unforged,
517 + queue: &Unqueued,
513 518 themes: &themes,
514 519 };
515 520
@@ -559,6 +564,7 @@
559 564 integrity: &Sound,
560 565 editor: &Unedited,
561 566 forge: &Unforged,
567 + queue: &Unqueued,
562 568 themes: &themes,
563 569 };
564 570 let refused = router().handle(
@@ -594,6 +600,7 @@
594 600 integrity: &Sound,
595 601 editor: &Unedited,
596 602 forge: &Unforged,
603 + queue: &Unqueued,
597 604 themes: &themes,
598 605 };
599 606
@@ -652,6 +659,7 @@
652 659 integrity: &Sound,
653 660 editor: &Unedited,
654 661 forge: &Unforged,
662 + queue: &Unqueued,
655 663 themes: &themes,
656 664 };
657 665 let response = router()
@@ -706,6 +714,7 @@
706 714 integrity: &Sound,
707 715 editor: &Unedited,
708 716 forge: &Unforged,
717 + queue: &Unqueued,
709 718 themes: &themes,
710 719 };
711 720 let response = router()
@@ -866,6 +875,7 @@
866 875 integrity: &Sound,
867 876 editor: &Unedited,
868 877 forge: &Unforged,
878 + queue: &Unqueued,
869 879 themes: &themes,
870 880 };
871 881 router().handle(&state, request)
@@ -1887,6 +1897,7 @@
1887 1897 integrity: &Sound,
1888 1898 editor: &Unedited,
1889 1899 forge: &Unforged,
1900 + queue: &Unqueued,
1890 1901 themes: &themes,
1891 1902 };
1892 1903 router().handle(&state, request)
@@ -2422,6 +2433,7 @@
2422 2433 integrity: &Sound,
2423 2434 editor: &Unedited,
2424 2435 forge: &Unforged,
2436 + queue: &Unqueued,
2425 2437 themes: &themes,
2426 2438 };
2427 2439 router().handle(&state, request)
@@ -2797,6 +2809,7 @@
2797 2809 integrity: &Sound,
2798 2810 editor: &Unedited,
2799 2811 forge: &Unforged,
2812 + queue: &Unqueued,
2800 2813 themes: &themes,
2801 2814 };
2802 2815 router().handle(&state, request)
@@ -2852,6 +2865,7 @@
2852 2865 integrity: &Sound,
2853 2866 editor: &Unedited,
2854 2867 forge: &Unforged,
2868 + queue: &Unqueued,
2855 2869 themes: &themes,
2856 2870 };
2857 2871
@@ -3082,6 +3096,7 @@
3082 3096 integrity: &Sound,
3083 3097 editor: &Unedited,
3084 3098 forge: &Unforged,
3099 + queue: &Unqueued,
3085 3100 themes: &themes,
3086 3101 };
3087 3102 router().handle(&state, request)
@@ -3501,6 +3516,7 @@
3501 3516 integrity: &Sound,
3502 3517 editor: &Unedited,
3503 3518 forge: &Unforged,
3519 + queue: &Unqueued,
3504 3520 themes: &themes,
3505 3521 };
3506 3522 router().handle(&state, request)
@@ -3936,6 +3952,7 @@
3936 3952 integrity: &Sound,
3937 3953 editor: &Unedited,
3938 3954 forge: &Unforged,
3955 + queue: &Unqueued,
3939 3956 themes: &themes,
3940 3957 };
3941 3958 router().handle(&state, request)
@@ -4492,6 +4509,7 @@
4492 4509 integrity: &Sound,
4493 4510 editor: &Unedited,
4494 4511 forge: &Unforged,
4512 + queue: &Unqueued,
4495 4513 themes: &themes,
4496 4514 };
4497 4515 router().handle(&state, request)
@@ -4649,6 +4667,7 @@
4649 4667 integrity: &Sound,
4650 4668 editor: &Unedited,
4651 4669 forge: &Unforged,
4670 + queue: &Unqueued,
4652 4671 themes: &themes,
4653 4672 };
4654 4673 router().handle(&state, request)
@@ -4953,6 +4972,7 @@
4953 4972 integrity,
4954 4973 editor: &Unedited,
4955 4974 forge: &Unforged,
4975 + queue: &Unqueued,
4956 4976 themes: &themes,
4957 4977 };
4958 4978 router().handle(&state, request)
@@ -5034,6 +5054,7 @@
5034 5054 integrity: &vault,
5035 5055 editor: &Unedited,
5036 5056 forge: &Unforged,
5057 + queue: &Unqueued,
5037 5058 themes: &themes,
5038 5059 };
5039 5060 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -5213,6 +5234,7 @@
5213 5234 integrity: &Sound,
5214 5235 editor,
5215 5236 forge: &Unforged,
5237 + queue: &Unqueued,
5216 5238 themes: &themes,
5217 5239 };
5218 5240 router().handle(&state, request)
@@ -6788,6 +6810,7 @@
6788 6810 integrity: &Sound,
6789 6811 editor: &Unedited,
6790 6812 forge,
6813 + queue: &Unqueued,
6791 6814 themes: &themes,
6792 6815 };
6793 6816 router().handle(&state, request)
@@ -7125,3 +7148,397 @@
7125 7148 let quiet = FakeShell::default();
7126 7149 assert!(showing(&quiet, Request::post("/storage/pause")).is_err());
7127 7150 }
7151 +
7152 + // --- the tag queue ---
7153 +
7154 + /// Nothing is queued, and nothing can be accepted.
7155 + ///
7156 + /// [`Idle`], [`NoImport`] and [`Unforged`]'s fourth peer.
7157 + struct Unqueued;
7158 +
7159 + impl Queue for Unqueued {
7160 + fn queued(&self) -> Option<Queued> {
7161 + None
7162 + }
7163 + fn read(&self, _at: usize) {}
7164 + fn tick(&self, _at: usize) {}
7165 + fn tick_shown(&self, _ticked: bool) {}
7166 + fn accept(&self, _scope: Scope) {}
7167 + fn accept_confident(&self) {}
7168 + fn dismiss(&self) {}
7169 + fn rescan(&self) {}
7170 + fn close(&self) {}
7171 + }
7172 +
7173 + /// A queue in memory, recording what was asked of it.
7174 + struct FakeQueue {
7175 + queued: Option<Queued>,
7176 + asked: RefCell<Vec<String>>,
7177 + }
7178 +
7179 + impl FakeQueue {
7180 + fn with(queued: Queued) -> Self {
7181 + Self {
7182 + queued: Some(queued),
7183 + asked: RefCell::new(Vec::new()),
7184 + }
7185 + }
7186 +
7187 + fn empty() -> Self {
7188 + Self {
7189 + queued: None,
7190 + asked: RefCell::new(Vec::new()),
7191 + }
7192 + }
7193 +
7194 + fn asked(&self) -> Vec<String> {
7195 + self.asked.borrow().clone()
7196 + }
7197 +
7198 + fn say(&self, said: impl Into<String>) {
7199 + self.asked.borrow_mut().push(said.into());
7200 + }
7201 + }
7202 +
7203 + impl Queue for FakeQueue {
7204 + fn queued(&self) -> Option<Queued> {
7205 + self.queued.clone()
7206 + }
7207 +
7208 + fn read(&self, at: usize) {
7209 + self.say(format!("read:{at}"));
7210 + }
7211 +
7212 + fn tick(&self, at: usize) {
7213 + self.say(format!("tick:{at}"));
7214 + }
7215 +
7216 + fn tick_shown(&self, ticked: bool) {
7217 + self.say(format!("tick:shown={ticked}"));
7218 + }
7219 +
7220 + fn accept(&self, scope: Scope) {
7221 + self.say(format!("accept:{}", scope.as_str()));
7222 + }
7223 +
7224 + fn accept_confident(&self) {
7225 + self.say("accept:everywhere");
7226 + }
7227 +
7228 + fn dismiss(&self) {
7229 + self.say("dismiss");
7230 + }
7231 +
7232 + fn rescan(&self) {
7233 + self.say("rescan");
7234 + }
7235 +
7236 + fn close(&self) {
7237 + self.say("close");
7238 + }
7239 + }
7240 +
7241 + /// One tag with something waiting under it.
7242 + fn group(tag: &str, candidates: usize, confident: usize, checked: usize) -> Group {
7243 + Group {
7244 + tag: tag.to_owned(),
7245 + candidates,
7246 + confident,
7247 + checked,
7248 + }
7249 + }
7250 +
7251 + /// One candidate for the open tag.
7252 + fn candidate(name: &str, score: f64, confident: bool, accepted: bool) -> Candidate {
7253 + Candidate {
7254 + name: name.to_owned(),
7255 + score,
7256 + confident,
7257 + accepted,
7258 + }
7259 + }
7260 +
7261 + /// A queue with two tags, the first one open.
7262 + fn queued() -> Queued {
7263 + Queued {
7264 + groups: vec![group("drums/kick", 340, 120, 0), group("texture", 12, 0, 0)],
7265 + at: 0,
7266 + considered: 4_000,
7267 + suggested: 352,
7268 + confident: 120,
7269 + rescanning: false,
7270 + said: None,
7271 + shown: vec![
7272 + candidate("kick_01.wav", 0.94, true, false),
7273 + candidate("kick_02.wav", 0.61, false, false),
7274 + ],
7275 + }
7276 + }
7277 +
7278 + /// A router call against this queue.
7279 + fn queueing(queue: &FakeQueue, request: Request) -> Result<Response, quasi_router::RouteError> {
7280 + let store = Store::default();
7281 + let sync = Offline;
7282 + let files = FakeFiles::default();
7283 + let themes = themes();
7284 + let state = Panels {
7285 + config: &store,
7286 + sync: &sync,
7287 + files: &files,
7288 + export: &Idle,
7289 + detail: &Unfocused,
7290 + bulk: &Unchosen,
7291 + shell: &Quiet,
7292 + library: &Empty,
7293 + bar: &Still,
7294 + naming: &Unnamed,
7295 + importing: &NoImport,
7296 + integrity: &Sound,
7297 + editor: &Unedited,
7298 + forge: &Unforged,
7299 + queue,
7300 + themes: &themes,
7301 + };
7302 + router().handle(&state, request)
7303 + }
7304 +
7305 + /// The review screen, with whatever is queued.
7306 + fn queue_screen(queue: &FakeQueue) -> Screen {
7307 + screen_of(&queueing(queue, Request::get("/review")).unwrap()).clone()
7308 + }
7309 +
7310 + #[test]
7311 + fn an_empty_queue_is_a_refusal_because_a_route_cannot_leave() {
7312 + // The shipped screen closes itself rather than drawing an empty shell, so
7313 + // "I finished" and "there was never anything" do not look the same. A route
7314 + // answers what is at an address, and this address stopped being a place.
7315 + let queue = FakeQueue::empty();
7316 + assert!(queueing(&queue, Request::get("/review")).is_err());
7317 + for address in [
7318 + "/review/accept/all",
7319 + "/review/accept-confident",
7320 + "/review/dismiss",
7321 + "/review/rescan",
7322 + "/review/close",
7323 + ] {
7324 + assert!(
7325 + queueing(&queue, Request::post(address)).is_err(),
7326 + "{address}"
7327 + );
7328 + }
7329 + }
7330 +
7331 + #[test]
7332 + fn the_promise_the_screen_rests_on_is_stated_on_the_screen() {
7333 + let queue = FakeQueue::with(queued());
7334 + let said = deep_said(&queue_screen(&queue));
7335 + assert!(
7336 + said.contains("Nothing is applied until you accept it."),
7337 + "{said}"
7338 + );
7339 + // And what the pass actually found, so the queue-wide button is a decision
7340 + // made with the count in view.
7341 + assert!(said.contains("352 suggestions across 2 tags"), "{said}");
7342 + assert!(said.contains("352 of 4000 samples"), "{said}");
7343 + }
7344 +
7345 + #[test]
7346 + fn the_tag_is_the_unit_of_navigation_and_the_open_one_says_so() {
7347 + // The design the shipped header argues for: 340 rows one at a time is 340
7348 + // questions nobody finishes.
7349 + let queue = FakeQueue::with(queued());
7350 + let screen = queue_screen(&queue);
7351 +
7352 + let tags = screen
7353 + .slots
7354 + .iter()
7355 + .find(|slot| slot.id == "review-split")
7356 + .expect("the screen is a split");
7357 + assert_eq!(tags.kind, quasi_router::RegionKind::Split);
7358 +
7359 + let rows = deep_rows(&screen);
7360 + let open = rows
7361 + .iter()
7362 + .find(|row| row.primary() == "drums/kick")
7363 + .expect("the open tag is listed");
7364 + assert!(open.current, "the open tag reads as open");
7365 + assert!(
7366 + rows.iter()
7367 + .find(|row| row.primary() == "texture")
7368 + .is_some_and(|row| !row.current)
7369 + );
7370 +
7371 + queueing(&queue, Request::post("/review/1/read")).unwrap();
7372 + assert_eq!(queue.asked(), ["read:1"]);
7373 + assert!(queueing(&queue, Request::post("/review/9/read")).is_err());
7374 + }
7375 +
7376 + #[test]
7377 + fn the_three_accept_scopes_are_offered_only_where_they_mean_something() {
7378 + // "Accept confident" beside "Accept all" when every candidate is confident
7379 + // is a second button that does what the first one does, and "Accept 0
7380 + // checked" is a control that reports having done nothing.
7381 + let queue = FakeQueue::with(queued());
7382 + let labels = deep_labels(&queue_screen(&queue));
7383 + assert!(labels.contains(&"Accept all 340".to_owned()), "{labels:?}");
7384 + assert!(
7385 + labels.contains(&"Accept 120 confident".to_owned()),
7386 + "{labels:?}"
7387 + );
7388 + assert!(
7389 + !labels.iter().any(|label| label.ends_with("checked")),
7390 + "{labels:?}"
7391 + );
7392 +
7393 + let all_confident = FakeQueue::with(Queued {
7394 + groups: vec![group("drums/kick", 340, 340, 3)],
7395 + ..queued()
7396 + });
7397 + let labels = deep_labels(&queue_screen(&all_confident));
7398 + assert!(
7399 + !labels.contains(&"Accept 340 confident".to_owned()),
7400 + "a subset of everything is not a subset: {labels:?}"
7401 + );
7402 + assert!(
7403 + labels.contains(&"Accept 3 checked".to_owned()),
7404 + "{labels:?}"
7405 + );
7406 +
7407 + // And each address refuses an empty scope, so a control the reader could
7408 + // still reach by typing is refused where the button was hidden.
7409 + assert!(queueing(&queue, Request::post("/review/accept/checked")).is_err());
7410 + queueing(&queue, Request::post("/review/accept/confident")).unwrap();
7411 + assert_eq!(queue.asked(), ["accept:confident"]);
7412 + assert!(queueing(&queue, Request::post("/review/accept/most")).is_err());
7413 + }
7414 +
7415 + #[test]
7416 + fn the_window_over_a_group_is_a_fact_about_the_data() {
7417 + // `files` refused to describe its own windowing because it holds every row.
7418 + // This one does not: a name is a backend call each, so the rows past the
7419 + // window were never resolved, and `Rest` is what says so.
7420 + let queue = FakeQueue::with(queued());
7421 + let more = deep_nodes(&queue_screen(&queue))
7422 + .into_iter()
7423 + .find_map(|node| match node {
7424 + Node::List { more, .. } => more,
7425 + _ => None,
7426 + })
7427 + .expect("the candidate list says what it is not showing");
7428 + assert_eq!(more.paging.total(), Some(340));
7429 + // No way to widen it: the buttons act on the whole group, which is the
7430 + // point of the cap.
7431 + assert!(more.forward.is_none());
7432 + assert!(more.back.is_none());
7433 +
7434 + // A group that fits says nothing, because there is nothing to say.
7435 + let small = FakeQueue::with(Queued {
7436 + groups: vec![group("texture", 2, 0, 0)],
7437 + at: 0,
7438 + ..queued()
7439 + });
7440 + assert!(
7441 + deep_nodes(&queue_screen(&small))
7442 + .into_iter()
7443 + .all(|node| !matches!(node, Node::List { more: Some(_), .. }))
7444 + );
7445 + }
7446 +
7447 + #[test]
7448 + fn nothing_arrives_ticked_and_ticking_is_bounded_to_what_is_drawn() {
7449 + // A screen opening with 340 boxes checked is auto-apply wearing a checkbox,
7450 + // and ticking 44,000 invisible ones would make "Accept checked" silently
7451 + // mean "accept everything".
7452 + let queue = FakeQueue::with(queued());
7453 + let screen = queue_screen(&queue);
7454 + let candidates: Vec<quasi_router::Row> = deep_rows(&screen)
7455 + .into_iter()
7456 + .filter(|row| row.selected.is_some())
7457 + .collect();
7458 + assert_eq!(candidates.len(), 2);
7459 + assert!(candidates.iter().all(|row| row.selected == Some(false)));
7460 +
7461 + let labels = deep_labels(&screen);
7462 + assert!(labels.contains(&"Check all shown".to_owned()), "{labels:?}");
7463 + // Uncheck appears only once something is ticked.
7464 + assert!(
7465 + !labels.contains(&"Uncheck all shown".to_owned()),
7466 + "{labels:?}"
7467 + );
7468 +
7469 + queueing(&queue, Request::post("/review/rows/check")).unwrap();
7470 + assert_eq!(queue.asked(), ["tick:shown=true"]);
7471 + assert!(queueing(&queue, Request::post("/review/rows/7/tick")).is_err());
7472 + }
Lines truncated
@@ -1,0 +1,449 @@
1 + //! The library-wide tag queue, described: what the layer would tag, and what is
2 + //! done about it.
3 + //!
4 + //! The fifteenth port. `ui/review_library.rs` is 384 lines and one screen, and
5 + //! the design it implements is stated in its own header: the tag is the unit of
6 + //! navigation and of action, individual samples exist to be opted *out* of, and
7 + //! nothing arrives ticked because a screen opening with 340 boxes already
8 + //! checked is auto-apply wearing a checkbox.
9 + //!
10 + //! Every one of those is a described fact rather than a drawing decision, which
11 + //! is why this port is mostly a straight reading. What it adds is one thing the
12 + //! shipped screen could only say in prose.
13 + //!
14 + //! # A window that is a fact about the data, and the file list's is not
15 + //!
16 + //! `RENDER_ROWS` draws the 200 strongest candidates of a group that may hold
17 + //! 44,000, and the screen says so in a muted line: "Showing the 200 strongest.
18 + //! The buttons above apply to all 44,000."
19 + //!
20 + //! [`files`](super::files) refused to describe its own windowing, and correctly:
21 + //! the file list holds every row it describes, so drawing a subset of them is a
22 + //! renderer's performance technique and `more` is `None`. **This one is the
23 + //! other case.** A candidate's display name costs one backend call, so
24 + //! `ensure_review_names` resolves the window and no further, and the rows past
25 + //! it have no name to carry. That is [`Rest`](quasi_router::Rest)'s own
26 + //! definition — "a fact about the data: there are rows that were never fetched,
27 + //! and only the thing that fetched them knows it" — so the list carries one and
28 + //! the muted line stops being the only place the fact lives.
29 + //!
30 + //! It is not an exact fit and the inexactness is worth one paragraph rather than
31 + //! a finding, since this is its only site. `Rest` is built for a window that can
32 + //! be widened, and this one cannot: there is no next page, by design, because
33 + //! the point of the cap is that the *buttons* act on the whole group and the
34 + //! list is a sample of it. So the `Rest` here carries a position and a total and
35 + //! no addresses, which the type allows and its header describes as the state of
36 + //! a last page. A renderer drawing a disabled Next is reading it right and
37 + //! saying slightly more than is true.
38 + //!
39 + //! # The queue can empty under the screen, and the two sides answer differently
40 + //!
41 + //! Accepting the last group removes it. The shipped screen leaves — `"I
42 + //! finished" and "there was never anything" should not look the same` — and
43 + //! writes a status line on the way out.
44 + //!
45 + //! A described screen cannot leave. A route answers what is at an address, and
46 + //! "this address is no longer a place" is a refusal rather than a navigation, so
47 + //! `GET /review` answers `NotFound` once the queue is empty. That reaches the
48 + //! same end by the only road available and it is a real difference: the shipped
49 + //! app puts you back where you were and this one tells the host there is nothing
50 + //! here. Which is right is the host's to decide, and the host is the one that
51 + //! knows where "back" was.
52 + //!
53 + //! # What is deliberately not described
54 + //!
55 + //! - **Up and down walking the tag list.** The second consumer of the same gap
56 + //! [`importing`](super::importing) recorded for the import review's side
57 + //! panel: [`Chrome`](quasi_router::Chrome) binds a key to an address, and
58 + //! "the next row of this list" is not one. Both screens have it and both lose
59 + //! it, which is worth the count even though the fix is not obvious.
60 + //! - **The confidence band as contrast.** The shipped row draws a confident
61 + //! candidate at full contrast and a review-band one muted, "so the band is
62 + //! visible without a second column of words". A renderer reading
63 + //! [`Candidate::confident`](super::Candidate::confident) can do exactly that;
64 + //! choosing the colour is not the description's.
65 +
66 + use quasi_router::layout::{Notice, Tone};
67 + use quasi_router::{
68 + Act, Action, Figure, Node, Prose, RegionKind, Request, Response, Rest, RouteError, Router, Row,
69 + Screen, Slot,
70 + };
71 +
72 + use super::{Group, Panels, Queued, Scope};
73 +
74 + /// The band above the queue.
75 + const HEAD: &str = "review-head";
76 +
77 + /// The tags, which is what this screen navigates by.
78 + const TAGS: &str = "review-tags";
79 +
80 + /// The open tag, which is what it acts on.
81 + const GROUP: &str = "review-group";
82 +
83 + /// The band under it.
84 + const FOOT: &str = "review-foot";
85 +
86 + /// Register the queue's routes.
87 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
88 + router
89 + .get("/review", index)
90 + .post("/review/accept-confident", accept_confident)
91 + .post("/review/rows/check", check_shown)
92 + .post("/review/rows/uncheck", uncheck_shown)
93 + .post("/review/rows/{at}/tick", tick)
94 + .post("/review/accept/{scope}", accept)
95 + .post("/review/dismiss", dismiss)
96 + .post("/review/rescan", rescan)
97 + .post("/review/close", close)
98 + .post("/review/{at}/read", read)
99 + }
100 +
101 + /// `GET /review`
102 + ///
103 + /// A refusal when there is nothing queued, which is the shipped screen's own
104 + /// exit said the only way a route can say it. See the module header.
105 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
106 + Ok(screen(&queued(state)?).into())
107 + }
108 +
109 + /// `POST /review/{at}/read`
110 + fn read(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
111 + let queued = queued(state)?;
112 + let at = group_at(&queued, &request)?;
113 + state.queue.read(at);
114 + Ok(screen(&queued).into())
115 + }
116 +
117 + /// `POST /review/rows/{at}/tick`
118 + ///
119 + /// Flips rather than sets, for the reason [`importing`](super::importing)'s
120 + /// judge route does: [`Row::toggling`](quasi_router::Row::toggling) says the
121 + /// tick is the write and a renderer fires it carrying no state of its own.
122 + fn tick(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
123 + let queued = queued(state)?;
124 + let at: usize = request
125 + .captures
126 + .require("at")?
127 + .parse()
128 + .map_err(|_| RouteError::not_found("no such candidate"))?;
129 + if at >= queued.shown.len() {
130 + return Err(RouteError::not_found("no such candidate"));
131 + }
132 + state.queue.tick(at);
133 + Ok(screen(&queued).into())
134 + }
135 +
136 + /// `POST /review/rows/check`
137 + fn check_shown(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
138 + let queued = queued(state)?;
139 + state.queue.tick_shown(true);
140 + Ok(screen(&queued).into())
141 + }
142 +
143 + /// `POST /review/rows/uncheck`
144 + fn uncheck_shown(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
145 + let queued = queued(state)?;
146 + state.queue.tick_shown(false);
147 + Ok(screen(&queued).into())
148 + }
149 +
150 + /// `POST /review/accept/{scope}`
151 + ///
152 + /// Refused where the scope names nothing, which is what each shipped button is
153 + /// hidden behind. Accepting zero checked candidates is a control that reports
154 + /// having done something it did not.
155 + fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
156 + let queued = queued(state)?;
157 + let name = request.captures.require("scope")?;
158 + let scope = Scope::from_key(name).ok_or_else(|| RouteError::not_found("no such scope"))?;
159 + let open = open_group(&queued)?;
160 +
161 + let counted = match scope {
162 + Scope::All => open.candidates,
163 + Scope::Confident => open.confident,
164 + Scope::Checked => open.checked,
165 + };
166 + if counted == 0 {
167 + return Err(RouteError::not_found("nothing is in that scope"));
168 + }
169 +
170 + state.queue.accept(scope);
171 + Ok(screen(&queued).into())
172 + }
173 +
174 + /// `POST /review/accept-confident`
175 + fn accept_confident(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
176 + let queued = queued(state)?;
177 + if queued.confident == 0 {
178 + return Err(RouteError::not_found("nothing clears its threshold"));
179 + }
180 + state.queue.accept_confident();
181 + Ok(screen(&queued).into())
182 + }
183 +
184 + /// `POST /review/dismiss`
185 + fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
186 + let queued = queued(state)?;
187 + open_group(&queued)?;
188 + state.queue.dismiss();
189 + Ok(screen(&queued).into())
190 + }
191 +
192 + /// `POST /review/rescan`
193 + ///
194 + /// Refused while a pass is running, which is what the shipped button is disabled
195 + /// on: a second pass over the same library would race the first for the queue it
196 + /// is building.
197 + fn rescan(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
198 + let queued = queued(state)?;
199 + if queued.rescanning {
200 + return Err(RouteError::not_found("a pass is already running"));
201 + }
202 + state.queue.rescan();
203 + Ok(screen(&queued).into())
204 + }
205 +
206 + /// `POST /review/close`
207 + fn close(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
208 + let queued = queued(state)?;
209 + state.queue.close();
210 + Ok(screen(&queued).into())
211 + }
212 +
213 + /// The queue, refusing the screen when there is nothing in it.
214 + fn queued(state: &Panels<'_>) -> Result<Queued, RouteError> {
215 + state
216 + .queue
217 + .queued()
218 + .ok_or_else(|| RouteError::not_found("nothing is waiting to be reviewed"))
219 + }
220 +
221 + /// The tag a request names.
222 + fn group_at(queued: &Queued, request: &Request) -> Result<usize, RouteError> {
223 + let at: usize = request
224 + .captures
225 + .require("at")?
226 + .parse()
227 + .map_err(|_| RouteError::not_found("no such tag"))?;
228 + if at >= queued.groups.len() {
229 + return Err(RouteError::not_found("no such tag"));
230 + }
231 + Ok(at)
232 + }
233 +
234 + /// The tag that is open.
235 + fn open_group(queued: &Queued) -> Result<&Group, RouteError> {
236 + queued
237 + .groups
238 + .get(queued.at)
239 + .ok_or_else(|| RouteError::not_found("no tag is open"))
240 + }
241 +
242 + /// The screen: what the pass found, the tags, and the open one.
243 + fn screen(queued: &Queued) -> Screen {
244 + Screen::sidebar_content("Review Tags")
245 + .with(head(queued))
246 + .with(
247 + Slot::new("review-split", RegionKind::Split)
248 + .with(Node::Region(tags(queued)))
249 + .with(Node::Region(group(queued))),
250 + )
251 + .with(foot(queued))
252 + }
253 +
254 + /// What the pass found, and the one queue-wide gesture.
255 + fn head(queued: &Queued) -> Slot {
256 + let mut band = Slot::new(HEAD, RegionKind::Band)
257 + .with(Node::page("Review Tags"))
258 + .with(Node::text(format!(
259 + "{} suggestion{} across {} tag{} \u{b7} {} of {} sample{}",
260 + total_of(queued),
261 + plural(total_of(queued)),
262 + queued.groups.len(),
263 + plural(queued.groups.len()),
264 + queued.suggested,
265 + queued.considered,
266 + plural(queued.considered),
267 + )))
268 + // The promise the whole screen rests on, and it is a fact about the
269 + // queue rather than about any control, so it is prose.
270 + .with(Node::text("Nothing is applied until you accept it."));
271 +
272 + // The closest thing left to the auto-apply this layer stopped doing, and
273 + // still a decision made with the count on screen. Offered only when there is
274 + // something in it, which is the shipped button's own gate.
275 + if queued.confident > 0 {
276 + band = band.with(Node::Act(
277 + Act::new(
278 + format!("Accept {} confident", queued.confident),
279 + Action::post("/review/accept-confident"),
280 + )
281 + .confirm(format!(
282 + "{} suggestions above their tags' thresholds will be applied across every tag. \
283 + Accept?",
284 + queued.confident
285 + )),
286 + ));
287 + }
288 +
289 + band
290 + }
291 +
292 + /// The tags, which is what this screen navigates by.
293 + fn tags(queued: &Queued) -> Slot {
294 + Slot::new(TAGS, RegionKind::Pane)
295 + .with(Node::section("Tags"))
296 + .with(Node::list(queued.groups.iter().enumerate().map(
297 + |(at, group)| {
298 + let mut row = Row::new(group.tag.clone())
299 + .secondary(Prose::Text(format!(
300 + "{} suggestion{}{}",
301 + group.candidates,
302 + plural(group.candidates),
303 + if group.confident > 0 {
304 + format!(", {} confident", group.confident)
305 + } else {
306 + String::new()
307 + }
308 + )))
309 + .activate(Action::post(format!("/review/{at}/read")));
310 + if at == queued.at {
311 + row.current = true;
312 + }
313 + row
314 + },
315 + )))
316 + }
317 +
318 + /// The open tag: what it would do, and to what.
319 + fn group(queued: &Queued) -> Slot {
320 + let pane = Slot::new(GROUP, RegionKind::Pane);
321 + let Some(open) = queued.groups.get(queued.at) else {
322 + return pane.with(Node::empty("Choose a tag to review."));
323 + };
324 +
325 + let mut pane = pane
326 + .with(Node::section(open.tag.clone()))
327 + .with(Node::text(format!(
328 + "{} sample{} would get this tag.",
329 + open.candidates,
330 + plural(open.candidates)
331 + )))
332 + .with(Node::Act(Act::new(
333 + format!("Accept all {}", open.candidates),
334 + Action::post("/review/accept/all"),
335 + )));
336 +
337 + // Only when it is a real subset. Otherwise it is a second button that does
338 + // what the first one does, which is the shipped screen's own gate.
339 + if open.confident > 0 && open.confident < open.candidates {
340 + pane = pane.with(Node::Act(Act::new(
341 + format!("Accept {} confident", open.confident),
342 + Action::post("/review/accept/confident"),
343 + )));
344 + }
345 + if open.checked > 0 {
346 + pane = pane.with(Node::Act(Act::new(
347 + format!("Accept {} checked", open.checked),
348 + Action::post("/review/accept/checked"),
349 + )));
350 + }
351 +
352 + pane = pane
353 + .with(Node::Act(
354 + Act::new("Dismiss tag", Action::post("/review/dismiss"))
355 + .tone(Tone::Danger)
356 + .confirm(format!(
357 + "\"{}\" and its {} suggestions will be dropped from the queue. Dismiss?",
358 + open.tag, open.candidates
359 + )),
360 + ))
361 + // Ticking is bounded to what is drawn, and that is deliberate rather
362 + // than incidental: ticking 44,000 invisible boxes would make "Accept
363 + // checked" silently mean "accept everything", which is the distinction
364 + // the three scopes exist to keep.
365 + .with(Node::Act(Act::new(
366 + "Check all shown",
367 + Action::post("/review/rows/check"),
368 + )));
369 + if open.checked > 0 {
370 + pane = pane.with(Node::Act(Act::new(
371 + "Uncheck all shown",
372 + Action::post("/review/rows/uncheck"),
373 + )));
374 + }
375 +
376 + if queued.shown.is_empty() {
377 + return pane.with(Node::empty("Nothing is waiting under this tag."));
378 + }
379 +
380 + pane.with(Node::List {
381 + rows: queued
382 + .shown
383 + .iter()
384 + .enumerate()
385 + .map(|(at, candidate)| {
386 + Row::new(candidate.name.clone())
387 + // The band as a fact rather than as a contrast level. A
388 + // renderer is free to draw it the way the shipped row does.
389 + .meta(format!(
390 + "{:.0}%{}",
391 + candidate.score * 100.0,
392 + if candidate.confident {
393 + " confident"
394 + } else {
395 + ""
396 + }
397 + ))
398 + .toggling(
399 + candidate.accepted,
400 + Action::post(format!("/review/rows/{at}/tick")),
401 + )
402 + })
403 + .collect(),
404 + // See the module header: a window over rows that were never fetched,
405 + // with no way to widen it because the buttons act on the whole group.
406 + more: (open.candidates > queued.shown.len())
407 + .then(|| Rest::page(0, queued.shown.len()).of(open.candidates)),
408 + })
409 + }
410 +
411 + /// The way out, and the way to start again.
412 + fn foot(queued: &Queued) -> Slot {
413 + let mut band = Slot::new(FOOT, RegionKind::Band)
414 + .with(Node::Act(Act::new("Close", Action::post("/review/close"))));
415 +
416 + let mut again = Act::new("Rescan library", Action::post("/review/rescan"));
417 + if queued.rescanning {
418 + // The reason `Act::disabled` cannot carry, as a line of its own. Sixth
419 + // consumer of `quasi:vocabulary:disabled-reason`.
420 + band = band.with(Node::text("A pass is running."));
421 + again = again.disabled();
422 + }
423 + band = band.with(Node::Act(again));
424 +
425 + // What the last accept did. A notice rather than prose, because it is the
426 + // app reporting rather than the screen describing, which is the split
427 + // `shell`'s status band already draws.
428 + match &queued.said {
429 + Some(said) => band.with(Node::Notice {
430 + kind: Notice::Toast,
431 + tone: Tone::Success,
432 + text: said.clone(),
433 + }),
434 + None => band.with(Node::Figure(Figure::new(
435 + queued.confident.to_string(),
436 + "confident",
437 + ))),
438 + }
439 + }
440 +
441 + /// How many suggestions the whole queue holds.
442 + fn total_of(queued: &Queued) -> usize {
443 + queued.groups.iter().map(|group| group.candidates).sum()
444 + }
445 +
446 + /// The plural `s`, or nothing.
447 + const fn plural(count: usize) -> &'static str {
448 + if count == 1 { "" } else { "s" }
449 + }