//! The library-wide tag queue, described: what the layer would tag, and what is //! done about it. //! //! The fifteenth port. `ui/review_library.rs` is 384 lines and one screen, and //! the design it implements is stated in its own header: the tag is the unit of //! navigation and of action, individual samples exist to be opted *out* of, and //! nothing arrives ticked because a screen opening with 340 boxes already //! checked is auto-apply wearing a checkbox. //! //! Every one of those is a described fact rather than a drawing decision, which //! is why this port is mostly a straight reading. What it adds is one thing the //! shipped screen could only say in prose. //! //! # A window that is a fact about the data, and the file list's is not //! //! `RENDER_ROWS` draws the 200 strongest candidates of a group that may hold //! 44,000, and the screen says so in a muted line: "Showing the 200 strongest. //! The buttons above apply to all 44,000." //! //! [`files`](super::files) refused to describe its own windowing, and correctly: //! the file list holds every row it describes, so drawing a subset of them is a //! renderer's performance technique and `more` is `None`. **This one is the //! other case.** A candidate's display name costs one backend call, so //! `ensure_review_names` resolves the window and no further, and the rows past //! it have no name to carry. That is [`Rest`](quasi_router::Rest)'s own //! definition — "a fact about the data: there are rows that were never fetched, //! and only the thing that fetched them knows it" — so the list carries one and //! the muted line stops being the only place the fact lives. //! //! It is not an exact fit and the inexactness is worth one paragraph rather than //! a finding, since this is its only site. `Rest` is built for a window that can //! be widened, and this one cannot: there is no next page, by design, because //! the point of the cap is that the *buttons* act on the whole group and the //! list is a sample of it. So the `Rest` here carries a position and a total and //! no addresses, which the type allows and its header describes as the state of //! a last page. A renderer drawing a disabled Next is reading it right and //! saying slightly more than is true. //! //! # The queue can empty under the screen, and the two sides answer differently //! //! Accepting the last group removes it. The shipped screen leaves — `"I //! finished" and "there was never anything" should not look the same` — and //! writes a status line on the way out. //! //! A described screen cannot leave. A route answers what is at an address, and //! "this address is no longer a place" is a refusal rather than a navigation, so //! `GET /review` answers `NotFound` once the queue is empty. That reaches the //! same end by the only road available and it is a real difference: the shipped //! app puts you back where you were and this one tells the host there is nothing //! here. Which is right is the host's to decide, and the host is the one that //! knows where "back" was. //! //! # What is deliberately not described //! //! - **Up and down walking the tag list.** The second consumer of the same gap //! [`importing`](super::importing) recorded for the import review's side //! panel: [`Chrome`](quasi_router::Chrome) binds a key to an address, and //! "the next row of this list" is not one. Both screens have it and both lose //! it, which is worth the count even though the fix is not obvious. //! - **The confidence band as contrast.** The shipped row draws a confident //! candidate at full contrast and a review-band one muted, "so the band is //! visible without a second column of words". A renderer reading //! [`Candidate::confident`](super::Candidate::confident) can do exactly that; //! choosing the colour is not the description's. use quasi_router::layout::{Notice, Tone}; use quasi_router::{ Act, Action, Figure, Node, Prose, RegionKind, Request, Response, Rest, RouteError, Router, Row, Screen, Slot, }; use super::{Group, Panels, Queued, Scope}; /// The band above the queue. const HEAD: &str = "review-head"; /// The tags, which is what this screen navigates by. const TAGS: &str = "review-tags"; /// The open tag, which is what it acts on. const GROUP: &str = "review-group"; /// The band under it. const FOOT: &str = "review-foot"; /// Candidate rows drawn for the selected group. /// /// A drawing and name-resolution budget, not a cap on the group: every act on /// this screen acts on all of it. Names are one backend call each, so resolving /// a 44,000-row group to fill two screens would stall the frame. /// /// Lived in `ui::review_library` until that module was deleted (2026-08-22), and /// came here rather than going with it: the described screen is what reads it /// now, and `BrowserState::ensure_review_names` takes it as an argument, so it /// is a fact about how much of a group is worth resolving rather than about any /// one renderer. pub const RENDER_ROWS: usize = 200; /// Register the queue's routes. pub fn routes(router: Router>) -> Router> { router .get("/review", index) .post("/review/accept-confident", accept_confident) .post("/review/rows/check", check_shown) .post("/review/rows/uncheck", uncheck_shown) .post("/review/rows/{at}/tick", tick) .post("/review/accept/{scope}", accept) .post("/review/dismiss", dismiss) .post("/review/rescan", rescan) .post("/review/close", close) .post("/review/{at}/read", read) } /// `GET /review` /// /// A refusal when there is nothing queued, which is the shipped screen's own /// exit said the only way a route can say it. See the module header. fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(&queued(state)?).into()) } /// `POST /review/{at}/read` fn read(state: &Panels<'_>, request: Request) -> Result { let queued = queued(state)?; let at = group_at(&queued, &request)?; state.queue.read(at); Ok(screen(&queued).into()) } /// `POST /review/rows/{at}/tick` /// /// Flips rather than sets, for the reason [`importing`](super::importing)'s /// judge route does: [`Row::toggling`](quasi_router::Row::toggling) says the /// tick is the write and a renderer fires it carrying no state of its own. fn tick(state: &Panels<'_>, request: Request) -> Result { let queued = queued(state)?; let at: usize = request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("no such candidate"))?; if at >= queued.shown.len() { return Err(RouteError::not_found("no such candidate")); } state.queue.tick(at); Ok(screen(&queued).into()) } /// `POST /review/rows/check` fn check_shown(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; state.queue.tick_shown(true); Ok(screen(&queued).into()) } /// `POST /review/rows/uncheck` fn uncheck_shown(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; state.queue.tick_shown(false); Ok(screen(&queued).into()) } /// `POST /review/accept/{scope}` /// /// Refused where the scope names nothing, which is what each shipped button is /// hidden behind. Accepting zero checked candidates is a control that reports /// having done something it did not. fn accept(state: &Panels<'_>, request: Request) -> Result { let queued = queued(state)?; let name = request.captures.require("scope")?; let scope = Scope::from_key(name).ok_or_else(|| RouteError::not_found("no such scope"))?; let open = open_group(&queued)?; let counted = match scope { Scope::All => open.candidates, Scope::Confident => open.confident, Scope::Checked => open.checked, }; if counted == 0 { return Err(RouteError::not_found("nothing is in that scope")); } state.queue.accept(scope); Ok(screen(&queued).into()) } /// `POST /review/accept-confident` fn accept_confident(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; if queued.confident == 0 { return Err(RouteError::not_found("nothing clears its threshold")); } state.queue.accept_confident(); Ok(screen(&queued).into()) } /// `POST /review/dismiss` fn dismiss(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; open_group(&queued)?; state.queue.dismiss(); Ok(screen(&queued).into()) } /// `POST /review/rescan` /// /// Refused while a pass is running, which is what the shipped button is disabled /// on: a second pass over the same library would race the first for the queue it /// is building. fn rescan(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; if queued.rescanning { return Err(RouteError::not_found("a pass is already running")); } state.queue.rescan(); Ok(screen(&queued).into()) } /// `POST /review/close` fn close(state: &Panels<'_>, _request: Request) -> Result { let queued = queued(state)?; state.queue.close(); Ok(screen(&queued).into()) } /// The queue, refusing the screen when there is nothing in it. fn queued(state: &Panels<'_>) -> Result { state .queue .queued() .ok_or_else(|| RouteError::not_found("nothing is waiting to be reviewed")) } /// The tag a request names. fn group_at(queued: &Queued, request: &Request) -> Result { let at: usize = request .captures .require("at")? .parse() .map_err(|_| RouteError::not_found("no such tag"))?; if at >= queued.groups.len() { return Err(RouteError::not_found("no such tag")); } Ok(at) } /// The tag that is open. fn open_group(queued: &Queued) -> Result<&Group, RouteError> { queued .groups .get(queued.at) .ok_or_else(|| RouteError::not_found("no tag is open")) } /// The screen: what the pass found, the tags, and the open one. fn screen(queued: &Queued) -> Screen { Screen::sidebar_content("Review Tags") .with(head(queued)) .with( Slot::new("review-split", RegionKind::Split) .with(Node::Region(tags(queued))) .with(Node::Region(group(queued))), ) .with(foot(queued)) } /// What the pass found, and the one queue-wide gesture. fn head(queued: &Queued) -> Slot { let mut band = Slot::new(HEAD, RegionKind::Band) .with(Node::page("Review Tags")) .with(Node::text(format!( "{} suggestion{} across {} tag{} \u{b7} {} of {} sample{}", total_of(queued), plural(total_of(queued)), queued.groups.len(), plural(queued.groups.len()), queued.suggested, queued.considered, plural(queued.considered), ))) // The promise the whole screen rests on, and it is a fact about the // queue rather than about any control, so it is prose. .with(Node::text("Nothing is applied until you accept it.")); // The closest thing left to the auto-apply this layer stopped doing, and // still a decision made with the count on screen. Offered only when there is // something in it, which is the shipped button's own gate. if queued.confident > 0 { band = band.with(Node::Act( Act::new( format!("Accept {} confident", queued.confident), Action::post("/review/accept-confident"), ) .confirm(format!( "{} suggestions above their tags' thresholds will be applied across every tag. \ Accept?", queued.confident )), )); } band } /// The tags, which is what this screen navigates by. fn tags(queued: &Queued) -> Slot { Slot::new(TAGS, RegionKind::Pane) .with(Node::section("Tags")) .with(Node::list(queued.groups.iter().enumerate().map( |(at, group)| { let mut row = Row::new(group.tag.clone()) .secondary(Prose::Text(format!( "{} suggestion{}{}", group.candidates, plural(group.candidates), if group.confident > 0 { format!(", {} confident", group.confident) } else { String::new() } ))) .activate(Action::post(format!("/review/{at}/read"))); if at == queued.at { row.current = true; } row }, ))) } /// The open tag: what it would do, and to what. fn group(queued: &Queued) -> Slot { let pane = Slot::new(GROUP, RegionKind::Pane); let Some(open) = queued.groups.get(queued.at) else { return pane.with(Node::empty("Choose a tag to review.")); }; let mut pane = pane .with(Node::section(open.tag.clone())) .with(Node::text(format!( "{} sample{} would get this tag.", open.candidates, plural(open.candidates) ))) .with(Node::Act(Act::new( format!("Accept all {}", open.candidates), Action::post("/review/accept/all"), ))); // Only when it is a real subset. Otherwise it is a second button that does // what the first one does, which is the shipped screen's own gate. if open.confident > 0 && open.confident < open.candidates { pane = pane.with(Node::Act(Act::new( format!("Accept {} confident", open.confident), Action::post("/review/accept/confident"), ))); } if open.checked > 0 { pane = pane.with(Node::Act(Act::new( format!("Accept {} checked", open.checked), Action::post("/review/accept/checked"), ))); } pane = pane .with(Node::Act( Act::new("Dismiss tag", Action::post("/review/dismiss")) .tone(Tone::Danger) .confirm(format!( "\"{}\" and its {} suggestions will be dropped from the queue. Dismiss?", open.tag, open.candidates )), )) // Ticking is bounded to what is drawn, and that is deliberate rather // than incidental: ticking 44,000 invisible boxes would make "Accept // checked" silently mean "accept everything", which is the distinction // the three scopes exist to keep. .with(Node::Act(Act::new( "Check all shown", Action::post("/review/rows/check"), ))); if open.checked > 0 { pane = pane.with(Node::Act(Act::new( "Uncheck all shown", Action::post("/review/rows/uncheck"), ))); } if queued.shown.is_empty() { return pane.with(Node::empty("Nothing is waiting under this tag.")); } pane.with(Node::List { rows: queued .shown .iter() .enumerate() .map(|(at, candidate)| { Row::new(candidate.name.clone()) // The band as a fact rather than as a contrast level. A // renderer is free to draw it the way the shipped row does. .meta(format!( "{:.0}%{}", candidate.score * 100.0, if candidate.confident { " confident" } else { "" } )) .toggling( candidate.accepted, Action::post(format!("/review/rows/{at}/tick")), ) }) .collect(), // See the module header: a window over rows that were never fetched, // with no way to widen it because the buttons act on the whole group. more: (open.candidates > queued.shown.len()) .then(|| Rest::page(0, queued.shown.len()).of(open.candidates)), }) } /// The way out, and the way to start again. fn foot(queued: &Queued) -> Slot { let mut band = Slot::new(FOOT, RegionKind::Band) .with(Node::Act(Act::new("Close", Action::post("/review/close")))); let mut again = Act::new("Rescan library", Action::post("/review/rescan")); if queued.rescanning { // The reason `Act::disabled` cannot carry, as a line of its own. Sixth // consumer of `quasi:vocabulary:disabled-reason`. band = band.with(Node::text("A pass is running.")); again = again.disabled(); } band = band.with(Node::Act(again)); // What the last accept did. A notice rather than prose, because it is the // app reporting rather than the screen describing, which is the split // `shell`'s status band already draws. match &queued.said { Some(said) => band.with(Node::Notice { kind: Notice::Toast, tone: Tone::Success, text: said.clone(), }), None => band.with(Node::Figure(Figure::new( queued.confident.to_string(), "confident", ))), } } /// How many suggestions the whole queue holds. fn total_of(queued: &Queued) -> usize { queued.groups.iter().map(|group| group.candidates).sum() } /// The plural `s`, or nothing. const fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "s" } }