max / audiofiles
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 | + | } |