max / audiofiles
9 files changed,
+894 insertions,
-41 deletions
| @@ -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" |
| @@ -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 | + | } |