Skip to main content

max / audiofiles

17.5 KB · 463 lines History Blame Raw
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 /// Candidate rows drawn for the selected group.
87 ///
88 /// A drawing and name-resolution budget, not a cap on the group: every act on
89 /// this screen acts on all of it. Names are one backend call each, so resolving
90 /// a 44,000-row group to fill two screens would stall the frame.
91 ///
92 /// Lived in `ui::review_library` until that module was deleted (2026-08-22), and
93 /// came here rather than going with it: the described screen is what reads it
94 /// now, and `BrowserState::ensure_review_names` takes it as an argument, so it
95 /// is a fact about how much of a group is worth resolving rather than about any
96 /// one renderer.
97 pub const RENDER_ROWS: usize = 200;
98
99 /// Register the queue's routes.
100 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
101 router
102 .get("/review", index)
103 .post("/review/accept-confident", accept_confident)
104 .post("/review/rows/check", check_shown)
105 .post("/review/rows/uncheck", uncheck_shown)
106 .post("/review/rows/{at}/tick", tick)
107 .post("/review/accept/{scope}", accept)
108 .post("/review/dismiss", dismiss)
109 .post("/review/rescan", rescan)
110 .post("/review/close", close)
111 .post("/review/{at}/read", read)
112 }
113
114 /// `GET /review`
115 ///
116 /// A refusal when there is nothing queued, which is the shipped screen's own
117 /// exit said the only way a route can say it. See the module header.
118 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
119 Ok(screen(&queued(state)?).into())
120 }
121
122 /// `POST /review/{at}/read`
123 fn read(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
124 let queued = queued(state)?;
125 let at = group_at(&queued, &request)?;
126 state.queue.read(at);
127 Ok(screen(&queued).into())
128 }
129
130 /// `POST /review/rows/{at}/tick`
131 ///
132 /// Flips rather than sets, for the reason [`importing`](super::importing)'s
133 /// judge route does: [`Row::toggling`](quasi_router::Row::toggling) says the
134 /// tick is the write and a renderer fires it carrying no state of its own.
135 fn tick(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
136 let queued = queued(state)?;
137 let at: usize = request
138 .captures
139 .require("at")?
140 .parse()
141 .map_err(|_| RouteError::not_found("no such candidate"))?;
142 if at >= queued.shown.len() {
143 return Err(RouteError::not_found("no such candidate"));
144 }
145 state.queue.tick(at);
146 Ok(screen(&queued).into())
147 }
148
149 /// `POST /review/rows/check`
150 fn check_shown(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
151 let queued = queued(state)?;
152 state.queue.tick_shown(true);
153 Ok(screen(&queued).into())
154 }
155
156 /// `POST /review/rows/uncheck`
157 fn uncheck_shown(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
158 let queued = queued(state)?;
159 state.queue.tick_shown(false);
160 Ok(screen(&queued).into())
161 }
162
163 /// `POST /review/accept/{scope}`
164 ///
165 /// Refused where the scope names nothing, which is what each shipped button is
166 /// hidden behind. Accepting zero checked candidates is a control that reports
167 /// having done something it did not.
168 fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
169 let queued = queued(state)?;
170 let name = request.captures.require("scope")?;
171 let scope = Scope::from_key(name).ok_or_else(|| RouteError::not_found("no such scope"))?;
172 let open = open_group(&queued)?;
173
174 let counted = match scope {
175 Scope::All => open.candidates,
176 Scope::Confident => open.confident,
177 Scope::Checked => open.checked,
178 };
179 if counted == 0 {
180 return Err(RouteError::not_found("nothing is in that scope"));
181 }
182
183 state.queue.accept(scope);
184 Ok(screen(&queued).into())
185 }
186
187 /// `POST /review/accept-confident`
188 fn accept_confident(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
189 let queued = queued(state)?;
190 if queued.confident == 0 {
191 return Err(RouteError::not_found("nothing clears its threshold"));
192 }
193 state.queue.accept_confident();
194 Ok(screen(&queued).into())
195 }
196
197 /// `POST /review/dismiss`
198 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
199 let queued = queued(state)?;
200 open_group(&queued)?;
201 state.queue.dismiss();
202 Ok(screen(&queued).into())
203 }
204
205 /// `POST /review/rescan`
206 ///
207 /// Refused while a pass is running, which is what the shipped button is disabled
208 /// on: a second pass over the same library would race the first for the queue it
209 /// is building.
210 fn rescan(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
211 let queued = queued(state)?;
212 if queued.rescanning {
213 return Err(RouteError::not_found("a pass is already running"));
214 }
215 state.queue.rescan();
216 Ok(screen(&queued).into())
217 }
218
219 /// `POST /review/close`
220 fn close(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
221 let queued = queued(state)?;
222 state.queue.close();
223 Ok(screen(&queued).into())
224 }
225
226 /// The queue, refusing the screen when there is nothing in it.
227 fn queued(state: &Panels<'_>) -> Result<Queued, RouteError> {
228 state
229 .queue
230 .queued()
231 .ok_or_else(|| RouteError::not_found("nothing is waiting to be reviewed"))
232 }
233
234 /// The tag a request names.
235 fn group_at(queued: &Queued, request: &Request) -> Result<usize, RouteError> {
236 let at: usize = request
237 .captures
238 .require("at")?
239 .parse()
240 .map_err(|_| RouteError::not_found("no such tag"))?;
241 if at >= queued.groups.len() {
242 return Err(RouteError::not_found("no such tag"));
243 }
244 Ok(at)
245 }
246
247 /// The tag that is open.
248 fn open_group(queued: &Queued) -> Result<&Group, RouteError> {
249 queued
250 .groups
251 .get(queued.at)
252 .ok_or_else(|| RouteError::not_found("no tag is open"))
253 }
254
255 /// The screen: what the pass found, the tags, and the open one.
256 fn screen(queued: &Queued) -> Screen {
257 Screen::sidebar_content("Review Tags")
258 .with(head(queued))
259 .with(
260 Slot::new("review-split", RegionKind::Split)
261 .with(Node::Region(tags(queued)))
262 .with(Node::Region(group(queued))),
263 )
264 .with(foot(queued))
265 }
266
267 /// What the pass found, and the one queue-wide gesture.
268 fn head(queued: &Queued) -> Slot {
269 let mut band = Slot::new(HEAD, RegionKind::Band)
270 .with(Node::page("Review Tags"))
271 .with(Node::text(format!(
272 "{} suggestion{} across {} tag{} \u{b7} {} of {} sample{}",
273 total_of(queued),
274 plural(total_of(queued)),
275 queued.groups.len(),
276 plural(queued.groups.len()),
277 queued.suggested,
278 queued.considered,
279 plural(queued.considered),
280 )))
281 // The promise the whole screen rests on, and it is a fact about the
282 // queue rather than about any control, so it is prose.
283 .with(Node::text("Nothing is applied until you accept it."));
284
285 // The closest thing left to the auto-apply this layer stopped doing, and
286 // still a decision made with the count on screen. Offered only when there is
287 // something in it, which is the shipped button's own gate.
288 if queued.confident > 0 {
289 band = band.with(Node::Act(
290 Act::new(
291 format!("Accept {} confident", queued.confident),
292 Action::post("/review/accept-confident"),
293 )
294 .confirm(format!(
295 "{} suggestions above their tags' thresholds will be applied across every tag. \
296 Accept?",
297 queued.confident
298 )),
299 ));
300 }
301
302 band
303 }
304
305 /// The tags, which is what this screen navigates by.
306 fn tags(queued: &Queued) -> Slot {
307 Slot::new(TAGS, RegionKind::Pane)
308 .with(Node::section("Tags"))
309 .with(Node::list(queued.groups.iter().enumerate().map(
310 |(at, group)| {
311 let mut row = Row::new(group.tag.clone())
312 .secondary(Prose::Text(format!(
313 "{} suggestion{}{}",
314 group.candidates,
315 plural(group.candidates),
316 if group.confident > 0 {
317 format!(", {} confident", group.confident)
318 } else {
319 String::new()
320 }
321 )))
322 .activate(Action::post(format!("/review/{at}/read")));
323 if at == queued.at {
324 row.current = true;
325 }
326 row
327 },
328 )))
329 }
330
331 /// The open tag: what it would do, and to what.
332 fn group(queued: &Queued) -> Slot {
333 let pane = Slot::new(GROUP, RegionKind::Pane);
334 let Some(open) = queued.groups.get(queued.at) else {
335 return pane.with(Node::empty("Choose a tag to review."));
336 };
337
338 let mut pane = pane
339 .with(Node::section(open.tag.clone()))
340 .with(Node::text(format!(
341 "{} sample{} would get this tag.",
342 open.candidates,
343 plural(open.candidates)
344 )))
345 .with(Node::Act(Act::new(
346 format!("Accept all {}", open.candidates),
347 Action::post("/review/accept/all"),
348 )));
349
350 // Only when it is a real subset. Otherwise it is a second button that does
351 // what the first one does, which is the shipped screen's own gate.
352 if open.confident > 0 && open.confident < open.candidates {
353 pane = pane.with(Node::Act(Act::new(
354 format!("Accept {} confident", open.confident),
355 Action::post("/review/accept/confident"),
356 )));
357 }
358 if open.checked > 0 {
359 pane = pane.with(Node::Act(Act::new(
360 format!("Accept {} checked", open.checked),
361 Action::post("/review/accept/checked"),
362 )));
363 }
364
365 pane = pane
366 .with(Node::Act(
367 Act::new("Dismiss tag", Action::post("/review/dismiss"))
368 .tone(Tone::Danger)
369 .confirm(format!(
370 "\"{}\" and its {} suggestions will be dropped from the queue. Dismiss?",
371 open.tag, open.candidates
372 )),
373 ))
374 // Ticking is bounded to what is drawn, and that is deliberate rather
375 // than incidental: ticking 44,000 invisible boxes would make "Accept
376 // checked" silently mean "accept everything", which is the distinction
377 // the three scopes exist to keep.
378 .with(Node::Act(Act::new(
379 "Check all shown",
380 Action::post("/review/rows/check"),
381 )));
382 if open.checked > 0 {
383 pane = pane.with(Node::Act(Act::new(
384 "Uncheck all shown",
385 Action::post("/review/rows/uncheck"),
386 )));
387 }
388
389 if queued.shown.is_empty() {
390 return pane.with(Node::empty("Nothing is waiting under this tag."));
391 }
392
393 pane.with(Node::List {
394 rows: queued
395 .shown
396 .iter()
397 .enumerate()
398 .map(|(at, candidate)| {
399 Row::new(candidate.name.clone())
400 // The band as a fact rather than as a contrast level. A
401 // renderer is free to draw it the way the shipped row does.
402 .meta(format!(
403 "{:.0}%{}",
404 candidate.score * 100.0,
405 if candidate.confident {
406 " confident"
407 } else {
408 ""
409 }
410 ))
411 .toggling(
412 candidate.accepted,
413 Action::post(format!("/review/rows/{at}/tick")),
414 )
415 })
416 .collect(),
417 // See the module header: a window over rows that were never fetched,
418 // with no way to widen it because the buttons act on the whole group.
419 more: (open.candidates > queued.shown.len())
420 .then(|| Rest::page(0, queued.shown.len()).of(open.candidates)),
421 })
422 }
423
424 /// The way out, and the way to start again.
425 fn foot(queued: &Queued) -> Slot {
426 let mut band = Slot::new(FOOT, RegionKind::Band)
427 .with(Node::Act(Act::new("Close", Action::post("/review/close"))));
428
429 let mut again = Act::new("Rescan library", Action::post("/review/rescan"));
430 if queued.rescanning {
431 // The reason `Act::disabled` cannot carry, as a line of its own. Sixth
432 // consumer of `quasi:vocabulary:disabled-reason`.
433 band = band.with(Node::text("A pass is running."));
434 again = again.disabled();
435 }
436 band = band.with(Node::Act(again));
437
438 // What the last accept did. A notice rather than prose, because it is the
439 // app reporting rather than the screen describing, which is the split
440 // `shell`'s status band already draws.
441 match &queued.said {
442 Some(said) => band.with(Node::Notice {
443 kind: Notice::Toast,
444 tone: Tone::Success,
445 text: said.clone(),
446 }),
447 None => band.with(Node::Figure(Figure::new(
448 queued.confident.to_string(),
449 "confident",
450 ))),
451 }
452 }
453
454 /// How many suggestions the whole queue holds.
455 fn total_of(queued: &Queued) -> usize {
456 queued.groups.iter().map(|group| group.candidates).sum()
457 }
458
459 /// The plural `s`, or nothing.
460 const fn plural(count: usize) -> &'static str {
461 if count == 1 { "" } else { "s" }
462 }
463