//! The main window, described: the file list with a status band under it. //! //! The eighth audiofiles port, and the first with **more than one region**. //! Every described screen before this is a single `RegionKind::Pane`, which //! meant `Screen::sidebar_content`'s arrangement had nothing to arrange and //! `RegionKind::Band` and `Sidebar` had never been written by this app at all. //! A description layer whose every screen is one box is not yet describing a //! layout. //! //! # What composing a region turns out to cost: nothing //! //! `files::body` was a private function building the list's slot and is now //! public, unchanged. The standalone `/files` window wraps it in a screen; this //! screen puts it beside a band. **Two callers, one description**, and the list //! did not have to learn that it might not be alone. That is the property a //! region is for, and it is worth stating because the alternative — a //! `files::screen` and a `files::embedded_screen` — is what every app that lacks //! regions ends up writing. //! //! # The footer, and the three things it stops storing //! //! `ui::footer::draw_footer` is 373 lines and carries a surprising amount of //! state that is not the app's: //! //! - **A fade timer.** The status line fades after five seconds and hides after //! thirty, tracked with `status_set_at`, two `Duration` constants, an //! `egui::Id` round-trip through `ctx.data` to notice the text changed, and a //! `request_repaint_after` to land the transition. All of it is renderer //! policy, which `Message::undo` settled in the vocabulary already: "How long //! an undo stays offered is renderer policy". The description says the status //! and its tone; how long a host keeps it up is the host's. //! - **A width breakpoint.** `let narrow = ctx.content_rect().width() < 1000.0` //! decides whether three items sit on the first row or a second one. That is a //! layout decision made from a pixel measurement inside a drawing function, //! and it is the clearest case in this app of the thing the description layer //! exists to take away. //! //! Worth being exact about what replaced it, because the obvious answer is //! the wrong one. `Ranked` (quasi 0.18.0) lets a placement say what it is //! worth when room runs out, and the toolbar's `< 900` collapse became three //! priorities. This band's `< 1000` did not, and should not: it *reflows*, //! moving three items to a second row and dropping nothing. Ranking them //! would delete facts the shipped footer keeps. Wrapping a row that does not //! fit is the host's arithmetic in its own units, which is what the deletion //! above already claimed, so nothing is owed here. //! //! The one member that is genuinely droppable is the tag badges, and they //! say so. //! - **A hand-painted progress bar.** Twenty lines of `rect_filled`, a bevel and //! a click-to-seek hit test. The bar is [`Meter`]; the seek is not (see //! below). //! //! # THE FINDING: `Meter` refuses the second of the two proportions here //! //! This band has two, and the vocabulary treats them differently: //! //! - **Analysis coverage** — "142/200 analyzed" — is a proportion of a set and //! exactly what [`Meter`] was added for. No argument. //! - **Playback position** is refused by `Meter`'s own header: "This is a //! proportion of a set and not the progress of an operation. A running timer //! or a fetch is imperative and live, and a screen is described once per //! answer." //! //! It is described as a `Meter` here anyway, and the reason is the finding the //! export port already filed (`quasi:docs:meter-refuses-progress`): the premise //! moved when `Runtime::reload` landed. A screen is no longer described once; it //! is described whenever the host asks, and this host asks every frame for //! exactly the reason the export flow does. Playback is the **second consumer** //! and the sharper one — the export progress at least moves when a worker //! finishes a file, and this moves at the sample clock with nobody touching //! anything. //! //! The type is right and the paragraph is wrong. Nothing here changes the type. //! //! # What is deliberately not described //! //! - **Click-to-seek on the progress bar.** The waveform's rule: a click that //! maps a pixel to a frame and writes into a mutex an audio thread is filling //! is a host fact, not a fact about a sample. //! - **The toolbar and the sidebar.** Two more regions and the reason this //! module is named for the shell rather than the footer: `toolbar.rs` (706) //! and `sidebar.rs` (722) are the other two bands of the same window, and each //! is its own pass. The screen below has the two regions that exist. //! //! # The migration strip, and why it is here rather than anywhere else //! //! `ui/layout_strip.rs` is 62 lines and its header argues, correctly, that //! moving blobs into hash-prefix shards deserves a strip rather than a modal or //! a mode: the migration auto-starts at vault open, the library stays usable //! while it runs because reads resolve both layouts, and seizing the window //! would be the wrong trade. That argument is about *where the fact goes*, which //! makes it this module's: it is a band of the main window, declared after the //! footer so it stacks above it. //! //! So there is no `/storage` address and no fourteenth capability for it. It is //! [`Shell::migrating`](super::Shell::migrating), a band that is there while the //! fact is true, and a described screen answering that band every time it is //! asked is what makes "appears while a job runs" sayable at all. Compare the //! loose-files warning three paragraphs down: the shipped app raises *that* as //! an overlay, which is the thing no description can do, and this one was //! already a band. //! //! Pause is honest here in a way a cancel usually is not, and the description //! says so on the control: the sweep is resumable and records nothing until a //! pass verifies the root is clean, so stopping defers the remainder rather than //! abandoning it. use quasi_router::layout::{Notice, Priority, Tone}; use quasi_router::{ Act, Action, Figure, Meter, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag, }; use super::{Panels, Playing, Saying}; /// The band under the list. const FOOT: &str = "shell-foot"; /// The band above it, while blobs are being moved. const STRIP: &str = "shell-migration"; /// Register the main screen's routes. pub fn routes(router: Router>) -> Router> { router .get("/", index) .post("/playback/stop", stop) .post("/hint/dismiss", dismiss) .post("/storage/pause", pause) } /// `GET /` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(state).into()) } /// `POST /playback/stop` fn stop(state: &Panels<'_>, _request: Request) -> Result { state.shell.stop(); Ok(screen(state).into()) } /// `POST /hint/dismiss` fn dismiss(state: &Panels<'_>, _request: Request) -> Result { state.shell.dismiss_hint(); Ok(screen(state).into()) } /// `POST /storage/pause` /// /// Refused when nothing is migrating, for the sweep's reason: the address is /// reachable by typing and pausing nothing is not a thing that happened. fn pause(state: &Panels<'_>, _request: Request) -> Result { if state.shell.migrating().is_none() { return Err(RouteError::not_found("no migration is running")); } state.shell.pause_migration(); Ok(screen(state).into()) } /// The window: what you can filter by, what is in it, and what it is doing. /// /// `pub(super)` because the sidebar's routes answer it. Every control in /// [`library`](super::library) changes what the *list* shows — choosing a vault, /// applying a tag filter, opening a collection — so the answer is the window /// rather than the corner of it that was pressed. pub(super) fn screen(state: &Panels<'_>) -> Screen { let screen = Screen::sidebar_content("audiofiles") .with(super::toolbar::body(state)) .with(super::library::body(state)) .with(super::files::body(state)); // Above the footer, which is where the shipped strip declares itself, and // only while there is something to say. See the header. match migrating(state) { Some(strip) => screen.with(strip).with(foot(state)), None => screen.with(foot(state)), } } /// Blobs being moved into their shards, while any are. fn migrating(state: &Panels<'_>) -> Option { let running = state.shell.migrating()?; Some( Slot::new(STRIP, RegionKind::Band) .with(Node::text("Optimising storage layout")) .with(Node::Meter( Meter::new(clamp(running.done), clamp(running.total)).label("files"), )) .with(Node::Act( Act::new("Pause", Action::post("/storage/pause")).confirm( "Stop for now. The remainder resumes the next time this vault opens. Pause?", ), )), ) } /// A count as the meter carries one. fn clamp(count: usize) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) } /// The status band. fn foot(state: &Panels<'_>) -> Slot { let mut band = Slot::new(FOOT, RegionKind::Band); if let Some(playing) = state.shell.playing() { band = transport(band, &playing); } let chosen = state.shell.chosen(); if chosen > 1 { band = band.with(Node::Figure(Figure::new(chosen.to_string(), "selected"))); } band = coverage(band, state); band = missing(band, state); band = saying(band, state); if let Some(device) = state.shell.device() { band = band.with(Node::text(format!("Preview: {device}"))); } else { // Said rather than omitted, because a silent preview with no device is // the case this line exists to make diagnosable without opening // Settings. band = band.with(Node::Text { text: "Preview: no device".to_owned(), tone: Tone::Warning, }); } // The focused sample's tags, inert. The shipped footer renders these as // muted text rather than chips on purpose -- "these are inert // (informational only), so the affordance contract should not invite a // click" -- which is exactly what a badge is and a chip is not. // // Optional, and they are the only thing in this band that is. A badge here // repeats a fact the detail panel states in full, so a window with no room // for everything loses the repetition first. Everything else in the footer // is a fact stated nowhere else on the screen. for tag in state.shell.tags() { band = band.with_ranked(Node::Token(Tag::badge(tag)), Priority::Optional); } band } /// What is playing, and how far through. fn transport(band: Slot, playing: &Playing) -> Slot { band.with(Node::text(format!("Playing: {}", playing.name))) .with(Node::Meter( Meter::new(playing.position, playing.total).label("seconds"), )) .with(Node::text(format!( "{}/{}", clock(playing.position), clock(playing.total) ))) .with(Node::Act( Act::new("Stop", Action::post("/playback/stop")).key("space"), )) } /// How much of what is on screen has been analysed. /// /// A [`Meter`] and a [`Figure`], where the shipped footer has two coloured /// strings. The proportion is the meter; the untagged count is a separate fact /// about the same set rather than a second proportion of it, which is why it is /// a figure and not a second bar. fn coverage(band: Slot, state: &Panels<'_>) -> Slot { let seen = state.shell.analysed(); if seen.samples == 0 { return band; } let done = seen.analysed == seen.samples; let mut band = band.with(Node::Meter( Meter::new(seen.analysed, seen.samples) .label("analysed") .tone(if done { Tone::Success } else { Tone::Neutral }), )); // Suppressed until analysis has produced something, which is the shipped // footer's rule: before the first result every sample is untagged and the // count says nothing. if seen.analysed > 0 && seen.untagged > 0 { band = band.with(Node::Figure(Figure::new( seen.untagged.to_string(), "untagged", ))); } band } /// The samples that have lost their files, and the way in to what can be done. /// /// **The band says it because no description can raise the overlay that says /// it.** The shipped app puts the warning up over whatever the user was doing, /// as soon as a vault load finds anything missing, and nothing a route answers /// can do that: `Outcome::Over` exists because something was pressed. See /// [`integrity`](super::integrity)'s header and the finding it shares with the /// import preflight. So the fact is stated where the app states its other facts /// and the modal is one act away. fn missing(band: Slot, state: &Panels<'_>) -> Slot { let missing = state.integrity.missing(); if missing == 0 { return band; } band.with(Node::Notice { kind: Notice::Banner, tone: Tone::Warning, text: format!( "{missing} sample{} cannot find {} file.", if missing == 1 { "" } else { "s" }, if missing == 1 { "its" } else { "their" }, ), }) .with(Node::Act(Act::new( "What is missing", Action::get("/library/loose-files"), ))) } /// Whatever the app is telling the user. /// /// Tone rather than a timer. The shipped footer decides the colour from /// `is_error_status`, a substring match over the message it is about to draw, /// and then decides how long to keep it up from two constants and an elapsed /// `Instant`. The first half is a fact the app knows when it writes the message /// and is what `Tone` carries; the second half is renderer policy and is gone. fn saying(band: Slot, state: &Panels<'_>) -> Slot { if let Some((text, saying)) = state.shell.status() { return band.with(Node::Notice { kind: Notice::Toast, tone: match saying { Saying::Failed => Tone::Danger, Saying::Ordinary => Tone::Neutral, }, text, }); } if state.shell.hinting() { return band .with(Node::text("Right-click for options. F1 for shortcuts.")) .with(Node::Act(Act::new( "Dismiss", Action::post("/hint/dismiss"), ))); } band } /// Seconds as the transport writes them. fn clock(seconds: u32) -> String { format!("{}:{:02}", seconds / 60, seconds % 60) }