Skip to main content

max / audiofiles

Describe the main window: the list, and the 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, so the arrangement had nothing to arrange and Band had never been written by this app at all -- a description layer whose every screen is one box is not yet describing a layout. Composing a region costs nothing, which is the result worth having. files::body was private and is now public, unchanged: the standalone /files window wraps it in a screen, and this screen puts it beside a band. Two callers, one description, and the list did not have to learn it might not be alone. The alternative -- a files::screen and a files::embedded_screen -- is what an app without regions ends up writing, and a test asserts the two tables are identical. The footer stops storing three things that were never the app's. A fade timer: 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 renderer policy, which Message::undo settled in the vocabulary already. A width breakpoint: content_rect().width() < 1000.0 deciding whether three items sit on a second row, which is a layout decision made from a pixel measurement inside a drawing function. And twenty lines of hand-painted progress bar. Second consumer for quasi:docs:meter-refuses-progress, and the sharper one. The band has two proportions and the vocabulary treats them differently: analysis coverage is a proportion of a set and exactly what Meter was added for, while playback position is the case Meter's header refuses as "a running timer". It is described as a Meter regardless, because Runtime::reload moved the premise that paragraph rests on -- and unlike the export flow, which advances when a worker finishes a file, this advances at the sample clock with nobody touching anything. The type is right; the paragraph is wrong. Not described: click-to-seek on the bar, by the waveform's rule. Not yet: the toolbar and the sidebar, the other two regions of the same window. 12 tests, 463 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 20:27 UTC
Signed with PGP, not checked
Commit: abbcd65fcf8ed6054fb431f3a106c75c288555cf
Parent: fafa664
9 files changed, +894 insertions, -41 deletions
M Cargo.lock +26 -26
@@ -4242,7 +4242,7 @@
4242 4242
4243 4243 [[package]]
4244 4244 name = "quasi-immediate"
4245 - version = "0.15.0"
4245 + version = "0.16.0"
4246 4246 dependencies = [
4247 4247 "docengine",
4248 4248 "egui",
@@ -4252,7 +4252,7 @@
4252 4252
4253 4253 [[package]]
4254 4254 name = "quasi-router"
4255 - version = "0.15.0"
4255 + version = "0.16.0"
4256 4256 dependencies = [
4257 4257 "makeover-layout",
4258 4258 ]
@@ -7541,6 +7541,30 @@
7541 7541 "winnow 1.0.4",
7542 7542 ]
7543 7543
7544 + [[patch.unused]]
7545 + name = "quasi-axum"
7546 + version = "0.16.0"
7547 +
7548 + [[patch.unused]]
7549 + name = "quasi-basics"
7550 + version = "0.16.0"
7551 +
7552 + [[patch.unused]]
7553 + name = "quasi-http"
7554 + version = "0.16.0"
7555 +
7556 + [[patch.unused]]
7557 + name = "quasi-store"
7558 + version = "0.1.0"
7559 +
7560 + [[patch.unused]]
7561 + name = "quasi-tauri"
7562 + version = "0.16.0"
7563 +
7564 + [[patch.unused]]
7565 + name = "quasi-webview"
7566 + version = "0.16.0"
7567 +
7544 7568 [[patch.unused]]
7545 7569 name = "kberg"
7546 7570 version = "0.1.0"
@@ -7552,27 +7576,3 @@
7552 7576 [[patch.unused]]
7553 7577 name = "painhours"
7554 7578 version = "0.1.0"
7555 -
7556 - [[patch.unused]]
7557 - name = "quasi-axum"
7558 - version = "0.15.0"
7559 -
7560 - [[patch.unused]]
7561 - name = "quasi-basics"
7562 - version = "0.15.0"
7563 -
7564 - [[patch.unused]]
7565 - name = "quasi-http"
7566 - version = "0.15.0"
7567 -
7568 - [[patch.unused]]
7569 - name = "quasi-store"
7570 - version = "0.1.0"
7571 -
7572 - [[patch.unused]]
7573 - name = "quasi-tauri"
7574 - version = "0.15.0"
7575 -
7576 - [[patch.unused]]
7577 - name = "quasi-webview"
7578 - version = "0.15.0"
M Cargo.toml +2 -2
@@ -21,8 +21,8 @@
21 21 makeover-immediate = "0.25.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.15" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.15" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.16" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.16" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -179,6 +179,10 @@
179 179 // shipped one is a pane in the main window, so there is no toggle
180 180 // to share and Settings opens it too.
181 181 state.described.show_detail = true;
182 + // And the main window, which is the one described screen that is
183 + // more than a single region: the file list with the status band
184 + // under it.
185 + state.described.show_shell = true;
182 186 }
183 187 }
184 188
@@ -192,6 +196,11 @@
192 196 crate::quasi::panel::draw_detail(ctx, state);
193 197 }
194 198
199 + #[cfg(feature = "quasi")]
200 + if state.described.show_shell {
201 + crate::quasi::panel::draw_shell(ctx, state);
202 + }
203 +
195 204 // The described export flow, beside whichever of the shipped export screens
196 205 // is showing. On the flow's own state rather than on a toggle, because the
197 206 // shipped side is not a window either: it takes over the central pane, and
@@ -124,11 +124,21 @@
124 124
125 125 /// The whole screen.
126 126 fn screen(state: &Panels<'_>) -> Screen {
127 + Screen::sidebar_content("Samples").with(body(state))
128 + }
129 +
130 + /// The list, as a region something else can hold.
131 + ///
132 + /// Public because [`shell`](super::shell) puts it inside the main screen rather
133 + /// than beside it: the file list is the app's central pane, so a described app
134 + /// composes this region while the standalone `/files` window answers it alone.
135 + /// Two callers, one description, which is what a region is for.
136 + pub fn body(state: &Panels<'_>) -> Slot {
127 137 let shown = state.files.columns();
128 138 let samples = state.files.samples();
129 139 let current = state.files.current();
130 140
131 - let body = if samples.is_empty() {
141 + if samples.is_empty() {
132 142 // The sentence and the way out are both on the node rather than on the
133 143 // region: `703f4cd2` settled that a region with a heading and no rows
134 144 // still has content, so emptiness belongs to the thing that is empty.
@@ -148,9 +158,7 @@
148 158 // this module's header.
149 159 more: None,
150 160 })
151 - };
152 -
153 - Screen::sidebar_content("Samples").with(body)
161 + }
154 162 }
155 163
156 164 /// The columns, in the order the shipped list puts them.
@@ -22,6 +22,7 @@
22 22 //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame |
23 23 //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame |
24 24 //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame |
25 + //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame |
25 26 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
26 27 //!
27 28 //! The themes are the settled rule from goingson's settings port applied first
@@ -77,6 +78,7 @@
77 78 pub mod help;
78 79 pub mod panel;
79 80 pub mod settings;
81 + pub mod shell;
80 82 pub mod sync;
81 83
82 84 use audiofiles_core::config_key::ConfigKey;
@@ -539,6 +541,10 @@
539 541 SpreadTag(String),
540 542 /// Untag every chosen sample that carries this tag.
541 543 StripTag(String),
544 + /// Stop the preview that is playing.
545 + StopPlayback,
546 + /// Put the first-launch hint away.
547 + DismissHint,
542 548 /// Tag or untag every chosen sample.
543 549 BulkTag(String, bool),
544 550 /// Move everything chosen into this folder, or to the root.
@@ -1603,6 +1609,193 @@
1603 1609 }
1604 1610 }
1605 1611
1612 + /// What is playing, as the band needs to name it.
1613 + ///
1614 + /// Whole seconds, because that is what the transport shows and what a [`Meter`]
1615 + /// takes. The frame-accurate position lives behind a mutex an audio thread is
1616 + /// filling and is not a fact a screen reports.
1617 + ///
1618 + /// [`Meter`]: quasi_router::Meter
1619 + #[derive(Debug, Clone, PartialEq, Eq)]
1620 + pub struct Playing {
1621 + /// What it is called.
1622 + pub name: String,
1623 + /// How far in, in seconds.
1624 + pub position: u32,
1625 + /// How long it runs, in seconds.
1626 + pub total: u32,
1627 + }
1628 +
1629 + /// How much of what is on screen analysis has got through.
1630 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1631 + pub struct Analysed {
1632 + /// How many samples are on screen.
1633 + pub samples: u32,
1634 + /// How many of them have been analysed.
1635 + pub analysed: u32,
1636 + /// How many carry no tags.
1637 + pub untagged: u32,
1638 + }
1639 +
1640 + /// What kind of thing the app is saying.
1641 + ///
1642 + /// Two, where the shipped footer decides by matching substrings against the
1643 + /// message it is about to draw (`is_error_status`: "failed", "error", "could
1644 + /// not", "cannot"). The classification is the app's and is made here by calling
1645 + /// that same function rather than by a second list of words; what changes is
1646 + /// that it becomes a described fact instead of a colour chosen at paint time.
1647 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1648 + pub enum Saying {
1649 + /// Something went wrong.
1650 + Failed,
1651 + /// Something happened.
1652 + Ordinary,
1653 + }
1654 +
1655 + /// The main window's own band, as much as a described screen needs.
1656 + ///
1657 + /// The seventh narrow trait, and the one that borrows least: nine methods, none
1658 + /// of which is a write to anything but the app's own playback and a dismissed
1659 + /// hint. What it deliberately does not offer is a way to *seek* — see
1660 + /// [`shell`]'s header on why the position is reported and not steered.
1661 + pub trait Shell {
1662 + /// What is playing, if anything is.
1663 + fn playing(&self) -> Option<Playing>;
1664 +
1665 + /// How many rows are chosen.
1666 + fn chosen(&self) -> usize;
1667 +
1668 + /// How far analysis has got through what is on screen.
1669 + fn analysed(&self) -> Analysed;
1670 +
1671 + /// What the app is saying, if it is saying anything.
1672 + fn status(&self) -> Option<(String, Saying)>;
1673 +
1674 + /// Whether the first-launch hint is still showing.
1675 + fn hinting(&self) -> bool;
1676 +
1677 + /// What preview plays through, if a device was found.
1678 + fn device(&self) -> Option<String>;
1679 +
1680 + /// The focused sample's tags.
1681 + fn tags(&self) -> Vec<String>;
1682 +
1683 + /// Stop the preview.
1684 + fn stop(&self);
1685 +
1686 + /// Put the first-launch hint away.
1687 + fn dismiss_hint(&self);
1688 + }
1689 +
1690 + /// The app's main window, as the narrow thing the described band borrows.
1691 + pub struct FromWindow<'a> {
1692 + /// What the app is showing and playing.
1693 + pub state: &'a crate::state::BrowserState,
1694 + /// What the described screen asked for, applied after the frame.
1695 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1696 + }
1697 +
1698 + impl Shell for FromWindow<'_> {
1699 + fn playing(&self) -> Option<Playing> {
1700 + let hash = self.state.preview.previewing_hash.as_deref()?;
1701 + let playback = self.state.shared.preview.lock();
1702 + if !playback.playing {
1703 + return None;
1704 + }
1705 + let buffer = playback.buffer.as_ref()?;
1706 + // Two channels interleaved, which is the shipped footer's own division
1707 + // and the reason it is here rather than in the description: how a
1708 + // buffer is laid out is not a fact about what is playing.
1709 + let frames = buffer.data.len() / 2;
1710 + let rate = f64::from(buffer.sample_rate);
1711 + if frames == 0 || rate <= 0.0 {
1712 + return None;
1713 + }
1714 + let name = self
1715 + .state
1716 + .nav
1717 + .contents
1718 + .iter()
1719 + .find(|node| node.node.sample_hash.as_deref() == Some(hash))
1720 + .map_or("...", |node| node.node.name.as_str())
1721 + .to_owned();
1722 + Some(Playing {
1723 + name,
1724 + position: seconds(playback.position_frac / rate),
1725 + total: seconds(frames as f64 / rate),
1726 + })
1727 + }
1728 +
1729 + fn chosen(&self) -> usize {
1730 + self.state.nav.selection.count()
1731 + }
1732 +
1733 + fn analysed(&self) -> Analysed {
1734 + let samples = self
1735 + .state
1736 + .nav
1737 + .contents
1738 + .iter()
1739 + .filter(|node| node.node.sample_hash.is_some());
1740 + let mut seen = Analysed::default();
1741 + for node in samples {
1742 + seen.samples += 1;
1743 + if node.duration.is_some() {
1744 + seen.analysed += 1;
1745 + }
1746 + if node.tags.is_empty() {
1747 + seen.untagged += 1;
1748 + }
1749 + }
1750 + seen
1751 + }
1752 +
1753 + fn status(&self) -> Option<(String, Saying)> {
1754 + if self.state.status.is_empty() {
1755 + return None;
1756 + }
1757 + Some((
1758 + self.state.status.clone(),
1759 + if crate::ui::footer::is_error_status(&self.state.status) {
1760 + Saying::Failed
1761 + } else {
1762 + Saying::Ordinary
1763 + },
1764 + ))
1765 + }
1766 +
1767 + fn hinting(&self) -> bool {
1768 + self.state.onboarding.show_first_launch_hint
1769 + }
1770 +
1771 + fn device(&self) -> Option<String> {
1772 + self.state.shared.preview_device_name.lock().clone()
1773 + }
1774 +
1775 + fn tags(&self) -> Vec<String> {
1776 + self.state.detail.selected_tags.as_ref().clone()
1777 + }
1778 +
1779 + fn stop(&self) {
1780 + self.intents.borrow_mut().push(Intent::StopPlayback);
1781 + }
1782 +
1783 + fn dismiss_hint(&self) {
1784 + self.intents.borrow_mut().push(Intent::DismissHint);
1785 + }
1786 + }
1787 +
1788 + /// A duration in seconds, as a whole number the transport can show.
1789 + fn seconds(value: f64) -> u32 {
1790 + #[expect(
1791 + clippy::cast_possible_truncation,
1792 + clippy::cast_sign_loss,
1793 + reason = "a sample's length in seconds is small and positive"
1794 + )]
1795 + let whole = value.max(0.0) as u32;
1796 + whole
1797 + }
1798 +
1606 1799 /// A theme the host resolved, as the description needs to name it.
1607 1800 ///
1608 1801 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -1636,6 +1829,8 @@
1636 1829 pub export: &'a dyn Export,
1637 1830 /// The selection, for the detail screen.
1638 1831 pub detail: &'a dyn Detail,
1832 + /// The window's own band, for the main screen.
1833 + pub shell: &'a dyn Shell,
1639 1834 /// The selection again, for the bulk screens. Two capabilities over one
1640 1835 /// selection rather than one, because they need different things of it and
1641 1836 /// the narrowing is the point: the detail screen may not move a file and
@@ -1651,8 +1846,8 @@
1651 1846 /// cost is nothing, and building it fresh is what lets the state borrow.
1652 1847 #[must_use]
1653 1848 pub fn router<'a>() -> Router<Panels<'a>> {
1654 - help::routes(bulk::routes(detail::routes(export::routes(files::routes(
1655 - sync::routes(settings::routes(Router::new())),
1849 + shell::routes(help::routes(bulk::routes(detail::routes(export::routes(
1850 + files::routes(sync::routes(settings::routes(Router::new()))),
1656 1851 )))))
1657 1852 }
1658 1853
@@ -33,8 +33,8 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBulk, FromContents, FromExport, FromSelection, FromSyncManager, Intent,
37 - Panels, Setting, Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBulk, FromContents, FromExport, FromSelection, FromSyncManager, FromWindow,
37 + Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
38 38 };
39 39 use crate::state::BrowserState;
40 40 use crate::ui::theme;
@@ -51,6 +51,9 @@
51 51 files: Option<Runtime>,
52 52 export: Option<Runtime>,
53 53 detail: Option<Runtime>,
54 + shell: Option<Runtime>,
55 + /// Whether the described main window is open.
56 + pub show_shell: bool,
54 57 /// Whether the described detail panel is open.
55 58 ///
56 59 /// [`show_files`](Self::show_files)'s twin and for the same reason: the
@@ -229,6 +232,39 @@
229 232 }
230 233 }
231 234
235 + /// Draw the described main window, and act on whatever was pressed.
236 + ///
237 + /// **Refreshed unconditionally**, and it is the third window to need that for a
238 + /// third reason. Settings and Sync move when something described is pressed;
239 + /// the export flow moves because a worker is writing files; this one moves
240 + /// because a sample is playing. The transport's position advances at the sample
241 + /// clock with nobody touching anything, which is the case `shell`'s header calls
242 + /// the sharper consumer of the `Meter` finding.
243 + pub fn draw_shell(ctx: &egui::Context, state: &mut BrowserState) {
244 + let intents = RefCell::new(Vec::new());
245 + let mut runtime = state.described.shell.take();
246 + let host = Host {
247 + state,
248 + sync: None,
249 + themes: themes(),
250 + intents: &intents,
251 + };
252 + let closed = window(
253 + ctx,
254 + "audiofiles (described)",
255 + &mut runtime,
256 + &host,
257 + "/",
258 + true,
259 + );
260 + state.described.shell = runtime;
261 + apply(ctx, state, intents.into_inner());
262 + if closed {
263 + state.described.show_shell = false;
264 + state.described.shell = None;
265 + }
266 + }
267 +
232 268 /// Do what a described screen asked the app to do to itself.
233 269 ///
234 270 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -339,6 +375,8 @@
339 375 // partial-failure counting, and none of that should exist twice.
340 376 // See `Bulk`'s header on why the description does not carry
341 377 // `BulkModal` even though the commit path does.
378 + Intent::StopPlayback => state.stop_preview(),
379 + Intent::DismissHint => state.dismiss_first_launch_hint(),
342 380 Intent::BulkTag(typed, adding) => {
343 381 state.open_bulk_tag_modal();
344 382 if let Some(crate::state::BulkModal::Tag {
@@ -706,6 +744,7 @@
706 744 let export = FromExport { state, intents };
707 745 let detail = FromSelection { state, intents };
708 746 let bulk = FromBulk { state, intents };
747 + let shell = FromWindow { state, intents };
709 748 let panels = Panels {
710 749 config: &config,
711 750 sync,
@@ -713,6 +752,7 @@
713 752 export: &export,
714 753 detail: &detail,
715 754 bulk: &bulk,
755 + shell: &shell,
716 756 themes,
717 757 };
718 758 super::router()
@@ -12,10 +12,10 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Analysis, Bulk, Channels, Chosen, ColumnsShown, Config, Coverage, Detail, Detailed, Export,
16 - Files, Focus, Folder, Format, Panels, Phase, Pricing, ProfileChoice, Sample, Setting, Settings,
17 - Shared, Source, Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged,
18 - ThemeChoice, router,
15 + Analysed, Analysis, Bulk, Channels, Chosen, ColumnsShown, Config, Coverage, Detail, Detailed,
16 + Export, Files, Focus, Folder, Format, Panels, Phase, Playing, Pricing, ProfileChoice, Sample,
17 + Saying, Setting, Settings, Shared, Shell, Source, Spread, State, Status, Subject, Subscription,
18 + Suggested, Sync, Tagged, ThemeChoice, router,
19 19 };
20 20
21 21 /// A config store in memory.
@@ -210,6 +210,7 @@
210 210 let state = Panels {
211 211 detail: &Unfocused,
212 212 bulk: &Unchosen,
213 + shell: &Quiet,
213 214 config: &store,
214 215 sync: &sync,
215 216 files: &files,
@@ -268,6 +269,7 @@
268 269 let state = Panels {
269 270 detail: &Unfocused,
270 271 bulk: &Unchosen,
272 + shell: &Quiet,
271 273 config: &store,
272 274 sync: &sync,
273 275 files,
@@ -397,6 +399,7 @@
397 399 let state = Panels {
398 400 detail: &Unfocused,
399 401 bulk: &Unchosen,
402 + shell: &Quiet,
400 403 config: &store,
401 404 sync: &sync,
402 405 files: &files,
@@ -444,6 +447,7 @@
444 447 let state = Panels {
445 448 detail: &Unfocused,
446 449 bulk: &Unchosen,
450 + shell: &Quiet,
447 451 config: &store,
448 452 sync: &sync,
449 453 files: &files,
@@ -485,6 +489,7 @@
485 489 let state = Panels {
486 490 detail: &Unfocused,
487 491 bulk: &Unchosen,
492 + shell: &Quiet,
488 493 config: &store,
489 494 sync: &sync,
490 495 files: &files,
@@ -512,6 +517,7 @@
512 517 let state = Panels {
513 518 detail: &Unfocused,
514 519 bulk: &Unchosen,
520 + shell: &Quiet,
515 521 config: &store,
516 522 sync: &sync,
517 523 files: &files,
@@ -562,6 +568,7 @@
562 568 let state = Panels {
563 569 detail: &Unfocused,
564 570 bulk: &Unchosen,
571 + shell: &Quiet,
565 572 config: &store,
566 573 sync: &sync,
567 574 files: &files,
@@ -608,6 +615,7 @@
608 615 let state = Panels {
609 616 detail: &Unfocused,
610 617 bulk: &Unchosen,
618 + shell: &Quiet,
611 619 config: &store,
612 620 sync: &sync,
613 621 files: &files,
@@ -760,6 +768,7 @@
760 768 let state = Panels {
761 769 detail: &Unfocused,
762 770 bulk: &Unchosen,
771 + shell: &Quiet,
763 772 config: &store,
764 773 sync,
765 774 files: &files,
@@ -1779,6 +1788,7 @@
1779 1788 export: &Idle,
1780 1789 detail,
1781 1790 bulk: &Unchosen,
1791 + shell: &Quiet,
1782 1792 themes: &themes,
1783 1793 };
1784 1794 router().handle(&state, request)
@@ -2306,6 +2316,7 @@
2306 2316 export: &Idle,
2307 2317 detail: &Unfocused,
2308 2318 bulk,
2319 + shell: &Quiet,
2309 2320 themes: &themes,
2310 2321 };
2311 2322 router().handle(&state, request)
@@ -2673,6 +2684,7 @@
2673 2684 export: &Idle,
2674 2685 detail: &Unfocused,
2675 2686 bulk: &Unchosen,
2687 + shell: &Quiet,
2676 2688 themes: &themes,
2677 2689 };
2678 2690 router().handle(&state, request)
@@ -2720,6 +2732,7 @@
2720 2732 export: &Idle,
2721 2733 detail: &Unfocused,
2722 2734 bulk: &bulk,
2735 + shell: &Quiet,
2723 2736 themes: &themes,
2724 2737 };
2725 2738
@@ -2825,3 +2838,357 @@
2825 2838 _ => None,
2826 2839 })
2827 2840 }
2841 +
2842 + // The main window.
2843 +
2844 + /// A window with nothing playing and nothing to say.
2845 + struct Quiet;
2846 +
2847 + impl Shell for Quiet {
2848 + fn playing(&self) -> Option<Playing> {
2849 + None
2850 + }
2851 +
2852 + fn chosen(&self) -> usize {
2853 + 0
2854 + }
2855 +
2856 + fn analysed(&self) -> Analysed {
2857 + Analysed::default()
2858 + }
2859 +
2860 + fn status(&self) -> Option<(String, Saying)> {
2861 + None
2862 + }
2863 +
2864 + fn hinting(&self) -> bool {
2865 + false
2866 + }
2867 +
2868 + fn device(&self) -> Option<String> {
2869 + Some("Built-in Output".to_owned())
2870 + }
2871 +
2872 + fn tags(&self) -> Vec<String> {
2873 + Vec::new()
2874 + }
2875 +
2876 + fn stop(&self) {}
2877 + fn dismiss_hint(&self) {}
2878 + }
2879 +
2880 + /// A window in memory, recording what was asked of it.
2881 + #[derive(Default)]
2882 + struct FakeShell {
2883 + playing: Option<Playing>,
2884 + chosen: usize,
2885 + analysed: Analysed,
2886 + status: Option<(String, Saying)>,
2887 + hinting: bool,
2888 + device: Option<String>,
2889 + tags: Vec<String>,
2890 + asked: RefCell<Vec<String>>,
2891 + }
2892 +
2893 + impl Shell for FakeShell {
2894 + fn playing(&self) -> Option<Playing> {
2895 + self.playing.clone()
2896 + }
2897 +
2898 + fn chosen(&self) -> usize {
2899 + self.chosen
2900 + }
2901 +
2902 + fn analysed(&self) -> Analysed {
2903 + self.analysed
2904 + }
2905 +
2906 + fn status(&self) -> Option<(String, Saying)> {
2907 + self.status.clone()
2908 + }
2909 +
2910 + fn hinting(&self) -> bool {
2911 + self.hinting
2912 + }
2913 +
2914 + fn device(&self) -> Option<String> {
2915 + self.device.clone()
2916 + }
2917 +
2918 + fn tags(&self) -> Vec<String> {
2919 + self.tags.clone()
2920 + }
2921 +
2922 + fn stop(&self) {
2923 + self.asked.borrow_mut().push("stop".to_owned());
2924 + }
2925 +
2926 + fn dismiss_hint(&self) {
2927 + self.asked.borrow_mut().push("dismiss".to_owned());
2928 + }
2929 + }
2930 +
2931 + /// A router call against this window.
2932 + fn showing(shell: &FakeShell, request: Request) -> Result<Response, quasi_router::RouteError> {
2933 + let store = Store::default();
2934 + let sync = Offline;
2935 + let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]);
2936 + let themes = themes();
2937 + let state = Panels {
2938 + config: &store,
2939 + sync: &sync,
2940 + files: &files,
2941 + export: &Idle,
2942 + detail: &Unfocused,
2943 + bulk: &Unchosen,
2944 + shell,
2945 + themes: &themes,
2946 + };
2947 + router().handle(&state, request)
2948 + }
2949 +
2950 + /// The main screen.
2951 + fn shown(shell: &FakeShell) -> Screen {
2952 + screen_of(&showing(shell, Request::get("/")).unwrap()).clone()
2953 + }
2954 +
2955 + /// The regions of a screen, by kind.
2956 + fn regions(screen: &Screen) -> Vec<(String, quasi_router::RegionKind)> {
2957 + screen
2958 + .slots
2959 + .iter()
2960 + .map(|slot| (slot.id.clone(), slot.kind.clone()))
2961 + .collect()
2962 + }
2963 +
2964 + /// Every meter on a screen.
2965 + fn meters(screen: &Screen) -> Vec<quasi_router::Meter> {
2966 + nodes(screen)
2967 + .iter()
2968 + .filter_map(|node| match node {
2969 + Node::Meter(meter) => Some(meter.clone()),
2970 + _ => None,
2971 + })
2972 + .collect()
2973 + }
2974 +
2975 + /// Every figure on a screen, as value and caption.
2976 + fn figures(screen: &Screen) -> Vec<(String, String)> {
2977 + nodes(screen)
2978 + .iter()
2979 + .filter_map(|node| match node {
2980 + Node::Figure(figure) => Some((figure.value.clone(), figure.caption.clone())),
2981 + _ => None,
2982 + })
2983 + .collect()
2984 + }
2985 +
2986 + #[test]
2987 + fn the_main_screen_is_a_list_and_a_band() {
2988 + // THE POINT OF THIS PORT. Every described screen before it was one Pane, so
2989 + // the arrangement had nothing to arrange and RegionKind::Band had never been
2990 + // written by this app.
2991 + let shell = FakeShell::default();
2992 + let screen = shown(&shell);
2993 +
2994 + assert_eq!(
2995 + regions(&screen),
2996 + [
2997 + ("files-body".to_owned(), quasi_router::RegionKind::Pane),
2998 + ("shell-foot".to_owned(), quasi_router::RegionKind::Band),
2999 + ]
3000 + );
3001 + }
3002 +
3003 + #[test]
3004 + fn the_list_region_is_the_same_description_the_files_window_answers() {
3005 + // `files::body` has two callers and one definition. If that ever stops
3006 + // being true this is what says so: the table in the main window and the
3007 + // table in the standalone window are the same rows and the same columns.
3008 + let shell = FakeShell::default();
3009 + let embedded = table_of(&shown(&shell));
3010 +
3011 + let files = FakeFiles::with(vec![sample(1, "kick.wav"), sample(2, "snare.wav")]);
3012 + let alone = table_of(screen_of(&listing(&files, Request::get("/files")).unwrap()));
3013 +
3014 + assert_eq!(embedded.0, alone.0);
3015 + assert_eq!(embedded.1, alone.1);
3016 + }
3017 +
3018 + #[test]
3019 + fn a_playing_sample_reports_its_position_as_a_proportion() {
3020 + // THE FINDING, and the second consumer of quasi:docs:meter-refuses-progress.
3021 + // `Meter`'s header says it is "a proportion of a set and not the progress of
3022 + // an operation", and playback position is exactly the refused case -- it
3023 + // moves at the sample clock with nobody touching anything. It is described
3024 + // as a Meter regardless, because `Runtime::reload` moved the premise the
3025 + // paragraph rests on.
3026 + let shell = FakeShell {
3027 + playing: Some(Playing {
3028 + name: "kick.wav".to_owned(),
3029 + position: 42,
3030 + total: 130,
3031 + }),
3032 + ..FakeShell::default()
3033 + };
3034 + let screen = shown(&shell);
3035 +
3036 + let transport = &meters(&screen)[0];
3037 + assert_eq!((transport.done, transport.total), (42, 130));
3038 +
3039 + // The clock is said as well as the bar, because a proportion is not a
3040 + // duration and a reader wants both.
3041 + assert!(said(&screen).contains("0:42/2:10"));
3042 + assert!(said(&screen).contains("Playing: kick.wav"));
3043 + assert!(acts(&screen).iter().any(|label| label == "Stop"));
3044 + }
3045 +
3046 + #[test]
3047 + fn nothing_playing_means_no_transport_at_all() {
3048 + let shell = FakeShell::default();
3049 + let screen = shown(&shell);
3050 + assert!(meters(&screen).is_empty());
3051 + assert!(!acts(&screen).iter().any(|label| label == "Stop"));
3052 + }
3053 +
3054 + #[test]
3055 + fn analysis_coverage_is_the_proportion_meter_was_added_for() {
3056 + let shell = FakeShell {
3057 + analysed: Analysed {
3058 + samples: 200,
3059 + analysed: 142,
3060 + untagged: 17,
3061 + },
3062 + ..FakeShell::default()
3063 + };
3064 + let screen = shown(&shell);
3065 +
3066 + let coverage = &meters(&screen)[0];
3067 + assert_eq!((coverage.done, coverage.total), (142, 200));
3068 + assert_eq!(coverage.tone, quasi_router::layout::Tone::Neutral);
3069 +
3070 + // The untagged count is a separate fact about the same set rather than a
3071 + // second proportion of it.
3072 + assert_eq!(figures(&screen), [("17".to_owned(), "untagged".to_owned())]);
3073 + }
3074 +
3075 + #[test]
3076 + fn a_fully_analysed_set_says_so_in_its_tone() {
3077 + let shell = FakeShell {
3078 + analysed: Analysed {
3079 + samples: 200,
3080 + analysed: 200,
3081 + untagged: 0,
3082 + },
3083 + ..FakeShell::default()
3084 + };
3085 + assert_eq!(
3086 + meters(&shown(&shell))[0].tone,
3087 + quasi_router::layout::Tone::Success
3088 + );
3089 + }
3090 +
3091 + #[test]
3092 + fn the_untagged_count_waits_for_analysis_to_produce_something() {
3093 + // The shipped footer's rule: before the first result every sample is
3094 + // untagged and the count says nothing.
3095 + let shell = FakeShell {
3096 + analysed: Analysed {
3097 + samples: 200,
3098 + analysed: 0,
3099 + untagged: 200,
3100 + },
3101 + ..FakeShell::default()
3102 + };
3103 + assert!(figures(&shown(&shell)).is_empty());
3104 + }
3105 +
3106 + #[test]
3107 + fn a_status_carries_its_tone_and_not_a_timer() {
3108 + // The shipped footer picks the colour by matching substrings against the
3109 + // message and then decides how long to keep it up from two constants and an
3110 + // elapsed Instant. The first is a described fact; the second is renderer
3111 + // policy and is gone.
3112 + let failed = FakeShell {
3113 + status: Some(("Import error: bad header".to_owned(), Saying::Failed)),
3114 + ..FakeShell::default()
3115 + };
3116 + let notices: Vec<quasi_router::layout::Tone> = nodes(&shown(&failed))
3117 + .iter()
3118 + .filter_map(|node| match node {
3119 + Node::Notice { tone, .. } => Some(*tone),
3120 + _ => None,
3121 + })
3122 + .collect();
3123 + assert_eq!(notices, [quasi_router::layout::Tone::Danger]);
3124 +
3125 + let fine = FakeShell {
3126 + status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)),
3127 + ..FakeShell::default()
3128 + };
3129 + assert!(said(&shown(&fine)).contains("Imported 42 samples"));
3130 + }
3131 +
3132 + #[test]
3133 + fn the_first_launch_hint_shows_only_while_there_is_nothing_to_say() {
3134 + let hinting = FakeShell {
3135 + hinting: true,
3136 + ..FakeShell::default()
3137 + };
3138 + assert!(
3139 + acts(&shown(&hinting))
3140 + .iter()
3141 + .any(|label| label == "Dismiss")
3142 + );
3143 +
3144 + // A status displaces it, which is the shipped footer's `else if`.
3145 + let both = FakeShell {
3146 + hinting: true,
3147 + status: Some(("Imported 42 samples".to_owned(), Saying::Ordinary)),
3148 + ..FakeShell::default()
3149 + };
3150 + assert!(!acts(&shown(&both)).iter().any(|label| label == "Dismiss"));
3151 + }
3152 +
3153 + #[test]
3154 + fn a_missing_preview_device_is_said_rather_than_left_out() {
3155 + // The line exists so a silent preview is diagnosable without opening
3156 + // Settings, so the case it exists for is the one that must not vanish.
3157 + let none = FakeShell::default();
3158 + let screen = shown(&none);
3159 + assert!(said(&screen).contains("Preview: no device"));
3160 +
3161 + let toned = nodes(&screen).iter().any(|node| {
3162 + matches!(node, Node::Text { text, tone }
3163 + if text.contains("no device") && *tone == quasi_router::layout::Tone::Warning)
3164 + });
3165 + assert!(toned, "a missing device is a warning, not an ordinary fact");
3166 + }
3167 +
3168 + #[test]
3169 + fn the_bands_writes_are_asked_for_rather_than_performed() {
3170 + let shell = FakeShell::default();
3171 + showing(&shell, Request::post("/playback/stop")).unwrap();
3172 + showing(&shell, Request::post("/hint/dismiss")).unwrap();
3173 + assert_eq!(shell.asked.borrow().as_slice(), ["stop", "dismiss"]);
3174 + }
3175 +
3176 + #[test]
3177 + fn a_lone_selection_is_not_counted_at_the_reader() {
3178 + // One row selected is what the app looks like most of the time, so saying
3179 + // "1 selected" is noise. The shipped footer's threshold, kept.
3180 + let one = FakeShell {
3181 + chosen: 1,
3182 + ..FakeShell::default()
3183 + };
3184 + assert!(figures(&shown(&one)).is_empty());
3185 +
3186 + let several = FakeShell {
3187 + chosen: 4,
3188 + ..FakeShell::default()
3189 + };
3190 + assert_eq!(
3191 + figures(&shown(&several)),
3192 + [("4".to_owned(), "selected".to_owned())]
3193 + );
3194 + }
@@ -15,7 +15,7 @@
15 15 /// Heuristic: does this status line report a failure? Error statuses are kept
16 16 /// visible (never auto-hidden) and rendered in `danger`, since a silently
17 17 /// expiring error is the one message the user most needs to catch.
18 - fn is_error_status(s: &str) -> bool {
18 + pub(crate) fn is_error_status(s: &str) -> bool {
19 19 let l = s.to_ascii_lowercase();
20 20 l.starts_with("failed")
21 21 || l.contains("error")
@@ -1,0 +1,234 @@
1 + //! The main window, described: the file list with a status band under it.
2 + //!
3 + //! The eighth audiofiles port, and the first with **more than one region**.
4 + //! Every described screen before this is a single `RegionKind::Pane`, which
5 + //! meant `Screen::sidebar_content`'s arrangement had nothing to arrange and
6 + //! `RegionKind::Band` and `Sidebar` had never been written by this app at all.
7 + //! A description layer whose every screen is one box is not yet describing a
8 + //! layout.
9 + //!
10 + //! # What composing a region turns out to cost: nothing
11 + //!
12 + //! `files::body` was a private function building the list's slot and is now
13 + //! public, unchanged. The standalone `/files` window wraps it in a screen; this
14 + //! screen puts it beside a band. **Two callers, one description**, and the list
15 + //! did not have to learn that it might not be alone. That is the property a
16 + //! region is for, and it is worth stating because the alternative — a
17 + //! `files::screen` and a `files::embedded_screen` — is what every app that lacks
18 + //! regions ends up writing.
19 + //!
20 + //! # The footer, and the three things it stops storing
21 + //!
22 + //! `ui::footer::draw_footer` is 373 lines and carries a surprising amount of
23 + //! state that is not the app's:
24 + //!
25 + //! - **A fade timer.** The status line fades after five seconds and hides after
26 + //! thirty, tracked with `status_set_at`, two `Duration` constants, an
27 + //! `egui::Id` round-trip through `ctx.data` to notice the text changed, and a
28 + //! `request_repaint_after` to land the transition. All of it is renderer
29 + //! policy, which `Message::undo` settled in the vocabulary already: "How long
30 + //! an undo stays offered is renderer policy". The description says the status
31 + //! and its tone; how long a host keeps it up is the host's.
32 + //! - **A width breakpoint.** `let narrow = ctx.content_rect().width() < 1000.0`
33 + //! decides whether three items sit on the first row or a second one. That is a
34 + //! layout decision made from a pixel measurement inside a drawing function,
35 + //! and it is the clearest case in this app of the thing the description layer
36 + //! exists to take away.
37 + //! - **A hand-painted progress bar.** Twenty lines of `rect_filled`, a bevel and
38 + //! a click-to-seek hit test. The bar is [`Meter`]; the seek is not (see
39 + //! below).
40 + //!
41 + //! # THE FINDING: `Meter` refuses the second of the two proportions here
42 + //!
43 + //! This band has two, and the vocabulary treats them differently:
44 + //!
45 + //! - **Analysis coverage** — "142/200 analyzed" — is a proportion of a set and
46 + //! exactly what [`Meter`] was added for. No argument.
47 + //! - **Playback position** is refused by `Meter`'s own header: "This is a
48 + //! proportion of a set and not the progress of an operation. A running timer
49 + //! or a fetch is imperative and live, and a screen is described once per
50 + //! answer."
51 + //!
52 + //! It is described as a `Meter` here anyway, and the reason is the finding the
53 + //! export port already filed (`quasi:docs:meter-refuses-progress`): the premise
54 + //! moved when `Runtime::reload` landed. A screen is no longer described once; it
55 + //! is described whenever the host asks, and this host asks every frame for
56 + //! exactly the reason the export flow does. Playback is the **second consumer**
57 + //! and the sharper one — the export progress at least moves when a worker
58 + //! finishes a file, and this moves at the sample clock with nobody touching
59 + //! anything.
60 + //!
61 + //! The type is right and the paragraph is wrong. Nothing here changes the type.
62 + //!
63 + //! # What is deliberately not described
64 + //!
65 + //! - **Click-to-seek on the progress bar.** The waveform's rule: a click that
66 + //! maps a pixel to a frame and writes into a mutex an audio thread is filling
67 + //! is a host fact, not a fact about a sample.
68 + //! - **The toolbar and the sidebar.** Two more regions and the reason this
69 + //! module is named for the shell rather than the footer: `toolbar.rs` (706)
70 + //! and `sidebar.rs` (722) are the other two bands of the same window, and each
71 + //! is its own pass. The screen below has the two regions that exist.
72 +
73 + use quasi_router::layout::{Notice, Tone};
74 + use quasi_router::{
75 + Act, Action, Figure, Meter, Node, RegionKind, Request, Response, RouteError, Router, Screen,
76 + Slot, Tag,
77 + };
78 +
79 + use super::{Panels, Playing, Saying};
80 +
81 + /// The band under the list.
82 + const FOOT: &str = "shell-foot";
83 +
84 + /// Register the main screen's routes.
85 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
86 + router
87 + .get("/", index)
88 + .post("/playback/stop", stop)
89 + .post("/hint/dismiss", dismiss)
90 + }
91 +
92 + /// `GET /`
93 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
94 + Ok(screen(state).into())
95 + }
96 +
97 + /// `POST /playback/stop`
98 + fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
99 + state.shell.stop();
100 + Ok(screen(state).into())
101 + }
102 +
103 + /// `POST /hint/dismiss`
104 + fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
105 + state.shell.dismiss_hint();
106 + Ok(screen(state).into())
107 + }
108 +
109 + /// The window: the list, and the band that reports on it.
110 + fn screen(state: &Panels<'_>) -> Screen {
111 + Screen::sidebar_content("audiofiles")
112 + .with(super::files::body(state))
113 + .with(foot(state))
114 + }
115 +
116 + /// The status band.
117 + fn foot(state: &Panels<'_>) -> Slot {
118 + let mut band = Slot::new(FOOT, RegionKind::Band);
119 +
120 + if let Some(playing) = state.shell.playing() {
121 + band = transport(band, &playing);
122 + }
123 +
124 + let chosen = state.shell.chosen();
125 + if chosen > 1 {
126 + band = band.with(Node::Figure(Figure::new(chosen.to_string(), "selected")));
127 + }
128 +
129 + band = coverage(band, state);
130 + band = saying(band, state);
131 +
132 + if let Some(device) = state.shell.device() {
133 + band = band.with(Node::text(format!("Preview: {device}")));
134 + } else {
135 + // Said rather than omitted, because a silent preview with no device is
136 + // the case this line exists to make diagnosable without opening
137 + // Settings.
138 + band = band.with(Node::Text {
139 + text: "Preview: no device".to_owned(),
140 + tone: Tone::Warning,
141 + });
142 + }
143 +
144 + // The focused sample's tags, inert. The shipped footer renders these as
145 + // muted text rather than chips on purpose -- "these are inert
146 + // (informational only), so the affordance contract should not invite a
147 + // click" -- which is exactly what a badge is and a chip is not.
148 + for tag in state.shell.tags() {
149 + band = band.with(Node::Token(Tag::badge(tag)));
150 + }
151 +
152 + band
153 + }
154 +
155 + /// What is playing, and how far through.
156 + fn transport(band: Slot, playing: &Playing) -> Slot {
157 + band.with(Node::text(format!("Playing: {}", playing.name)))
158 + .with(Node::Meter(
159 + Meter::new(playing.position, playing.total).label("seconds"),
160 + ))
161 + .with(Node::text(format!(
162 + "{}/{}",
163 + clock(playing.position),
164 + clock(playing.total)
165 + )))
166 + .with(Node::Act(
167 + Act::new("Stop", Action::post("/playback/stop")).key("space"),
168 + ))
169 + }
170 +
171 + /// How much of what is on screen has been analysed.
172 + ///
173 + /// A [`Meter`] and a [`Figure`], where the shipped footer has two coloured
174 + /// strings. The proportion is the meter; the untagged count is a separate fact
175 + /// about the same set rather than a second proportion of it, which is why it is
176 + /// a figure and not a second bar.
177 + fn coverage(band: Slot, state: &Panels<'_>) -> Slot {
178 + let seen = state.shell.analysed();
179 + if seen.samples == 0 {
180 + return band;
181 + }
182 +
183 + let done = seen.analysed == seen.samples;
184 + let mut band = band.with(Node::Meter(
185 + Meter::new(seen.analysed, seen.samples)
186 + .label("analysed")
187 + .tone(if done { Tone::Success } else { Tone::Neutral }),
188 + ));
189 +
190 + // Suppressed until analysis has produced something, which is the shipped
191 + // footer's rule: before the first result every sample is untagged and the
192 + // count says nothing.
193 + if seen.analysed > 0 && seen.untagged > 0 {
194 + band = band.with(Node::Figure(Figure::new(
195 + seen.untagged.to_string(),
196 + "untagged",
197 + )));
198 + }
199 + band
200 + }
201 +
202 + /// Whatever the app is telling the user.
203 + ///
204 + /// Tone rather than a timer. The shipped footer decides the colour from
205 + /// `is_error_status`, a substring match over the message it is about to draw,
206 + /// and then decides how long to keep it up from two constants and an elapsed
207 + /// `Instant`. The first half is a fact the app knows when it writes the message
208 + /// and is what `Tone` carries; the second half is renderer policy and is gone.
209 + fn saying(band: Slot, state: &Panels<'_>) -> Slot {
210 + if let Some((text, saying)) = state.shell.status() {
211 + return band.with(Node::Notice {
212 + kind: Notice::Toast,
213 + tone: match saying {
214 + Saying::Failed => Tone::Danger,
215 + Saying::Ordinary => Tone::Neutral,
216 + },
217 + text,
218 + });
219 + }
220 + if state.shell.hinting() {
221 + return band
222 + .with(Node::text("Right-click for options. F1 for shortcuts."))
223 + .with(Node::Act(Act::new(
224 + "Dismiss",
225 + Action::post("/hint/dismiss"),
226 + )));
227 + }
228 + band
229 + }
230 +
231 + /// Seconds as the transport writes them.
232 + fn clock(seconds: u32) -> String {
233 + format!("{}:{:02}", seconds / 60, seconds % 60)
234 + }