max / audiofiles
5 files changed,
+240 insertions,
-12 deletions
| @@ -7554,10 +7554,6 @@ | |||
| 7554 | 7554 | "winnow 1.0.4", | |
| 7555 | 7555 | ] | |
| 7556 | 7556 | ||
| 7557 | - | [[patch.unused]] | |
| 7558 | - | name = "quasi-type" | |
| 7559 | - | version = "0.1.0" | |
| 7560 | - | ||
| 7561 | 7557 | [[patch.unused]] | |
| 7562 | 7558 | name = "kberg" | |
| 7563 | 7559 | version = "0.1.0" | |
| @@ -7570,6 +7566,10 @@ | |||
| 7570 | 7566 | name = "painhours" | |
| 7571 | 7567 | version = "0.1.0" | |
| 7572 | 7568 | ||
| 7569 | + | [[patch.unused]] | |
| 7570 | + | name = "quasi-type" | |
| 7571 | + | version = "0.1.0" | |
| 7572 | + | ||
| 7573 | 7573 | [[patch.unused]] | |
| 7574 | 7574 | name = "quasi-axum" | |
| 7575 | 7575 | version = "0.52.0" |
| @@ -4934,6 +4934,18 @@ | |||
| 4934 | 4934 | pub name: String, | |
| 4935 | 4935 | /// `dark`, `light` or `high-contrast`. | |
| 4936 | 4936 | pub variant: String, | |
| 4937 | + | /// This theme's TOML, when the host could read it. | |
| 4938 | + | /// | |
| 4939 | + | /// Here rather than behind a capability method for the reason the rest of | |
| 4940 | + | /// `ThemeChoice` is: a theme's source is a host fact the app resolves, and | |
| 4941 | + | /// the settled rule is that a host fact the app can answer goes in `S`. A | |
| 4942 | + | /// capability that read a file would be a route touching this machine's | |
| 4943 | + | /// disk, which is the thing the narrow traits exist to prevent. | |
| 4944 | + | /// | |
| 4945 | + | /// `None` for a theme whose source is not readable -- a built-in compiled | |
| 4946 | + | /// in, or a custom file that has since moved. Export offers nothing in that | |
| 4947 | + | /// case rather than offering an empty file. | |
| 4948 | + | pub source: Option<String>, | |
| 4937 | 4949 | } | |
| 4938 | 4950 | ||
| 4939 | 4951 | /// Everything the described screens read and write. |
| @@ -1742,6 +1742,13 @@ | |||
| 1742 | 1742 | ||
| 1743 | 1743 | let step = runtime.show(ui, &immediate); | |
| 1744 | 1744 | perform(runtime, ui, host, step); | |
| 1745 | + | ||
| 1746 | + | // One drain for every described window, rather than one per | |
| 1747 | + | // `draw_*`: a file is produced by a route and a route is reachable | |
| 1748 | + | // from all of them, so the host answer belongs where the runtime is | |
| 1749 | + | // driven. Last in the frame because `perform` above is what may have | |
| 1750 | + | // just produced one. | |
| 1751 | + | hand_over(runtime, host.state); | |
| 1745 | 1752 | }); | |
| 1746 | 1753 | ||
| 1747 | 1754 | !open | |
| @@ -1815,6 +1822,72 @@ | |||
| 1815 | 1822 | /// How many `Goto`s one press may chain before the host calls it a loop. | |
| 1816 | 1823 | const REDIRECTS: usize = 8; | |
| 1817 | 1824 | ||
| 1825 | + | /// Put a file a route answered with wherever the user says. | |
| 1826 | + | /// | |
| 1827 | + | /// The host half of `Outcome::File`, which quasi ruled and shipped in 0.50.0 | |
| 1828 | + | /// (`67881a88`, Max: the route answers with the file and the host puts it | |
| 1829 | + | /// somewhere) and which nothing on this side had ever drained. A member with no | |
| 1830 | + | /// consumer reads as a member that does not work, and a described control that | |
| 1831 | + | /// produces a file was unbuildable here until this existed. | |
| 1832 | + | /// | |
| 1833 | + | /// **The description never names a path**, which is the whole point of the | |
| 1834 | + | /// ruling: `name` is a suggestion and `kind` is what sort of file it is. What | |
| 1835 | + | /// audiofiles does about that is open a save dialog, because it has one -- | |
| 1836 | + | /// `ui::dialog`, the subsystem that draws nothing and asks the operating system | |
| 1837 | + | /// a question. A terminal would write to the working directory and a browser | |
| 1838 | + | /// would download; one description, three hosts, three answers. | |
| 1839 | + | /// | |
| 1840 | + | /// Drained after `apply` rather than inside it: `Runtime::handed` is a one-slot | |
| 1841 | + | /// mailbox, so a second file replaces the first, and the frame that produced one | |
| 1842 | + | /// is the frame that should hand it over. | |
| 1843 | + | fn hand_over(runtime: &mut Runtime, state: &BrowserState) { | |
| 1844 | + | let Some(handed) = runtime.handed() else { | |
| 1845 | + | return; | |
| 1846 | + | }; | |
| 1847 | + | let quasi_immediate::Handed { name, kind, bytes } = handed; | |
| 1848 | + | // The dialog wants a filter, and `Accepted` is the same type the upload half | |
| 1849 | + | // uses rather than a second way to name a file kind. Only a suffix is a | |
| 1850 | + | // filter a native dialog can take; a family or a media type is a fact about | |
| 1851 | + | // the file rather than a list of extensions, so those offer no filter and | |
| 1852 | + | // the user picks freely. | |
| 1853 | + | let suffix = match &kind { | |
| 1854 | + | quasi_router::Accepted::Suffix(suffix) => Some(suffix.trim_start_matches('.').to_owned()), | |
| 1855 | + | // `Accepted` is `#[non_exhaustive]`, so a kind added later lands here | |
| 1856 | + | // and the user picks freely rather than the build breaking. | |
| 1857 | + | _ => None, | |
| 1858 | + | }; | |
| 1859 | + | let filters: Vec<(String, Vec<String>)> = suffix | |
| 1860 | + | .into_iter() | |
| 1861 | + | .map(|suffix| (suffix.to_uppercase(), vec![suffix])) | |
| 1862 | + | .collect(); | |
| 1863 | + | let borrowed: Vec<(&str, Vec<&str>)> = filters | |
| 1864 | + | .iter() | |
| 1865 | + | .map(|(label, suffixes)| { | |
| 1866 | + | ( | |
| 1867 | + | label.as_str(), | |
| 1868 | + | suffixes.iter().map(String::as_str).collect::<Vec<_>>(), | |
| 1869 | + | ) | |
| 1870 | + | }) | |
| 1871 | + | .collect(); | |
| 1872 | + | let borrowed: Vec<(&str, &[&str])> = borrowed | |
| 1873 | + | .iter() | |
| 1874 | + | .map(|(label, suffixes)| (*label, suffixes.as_slice())) | |
| 1875 | + | .collect(); | |
| 1876 | + | ||
| 1877 | + | state.dialogs.save_file( | |
| 1878 | + | "Save", | |
| 1879 | + | name, | |
| 1880 | + | &borrowed, | |
| 1881 | + | move |s, path| match std::fs::write(&path, &bytes) { | |
| 1882 | + | Ok(()) => s.status = format!("Saved to {}", path.display()), | |
| 1883 | + | Err(error) => { | |
| 1884 | + | tracing::error!("failed to write {}: {error}", path.display()); | |
| 1885 | + | s.status = format!("Could not save: {error}"); | |
| 1886 | + | } | |
| 1887 | + | }, | |
| 1888 | + | ); | |
| 1889 | + | } | |
| 1890 | + | ||
| 1818 | 1891 | /// Hand an address to the desktop. | |
| 1819 | 1892 | /// | |
| 1820 | 1893 | /// The one piece of platform knowledge in the port, and it is the host's by | |
| @@ -1899,6 +1972,7 @@ | |||
| 1899 | 1972 | theme::list_themes() | |
| 1900 | 1973 | .into_iter() | |
| 1901 | 1974 | .map(|meta| ThemeChoice { | |
| 1975 | + | source: theme::export_theme_content(&meta.id), | |
| 1902 | 1976 | id: meta.id, | |
| 1903 | 1977 | name: meta.name, | |
| 1904 | 1978 | variant: meta.variant, |
| @@ -22,19 +22,28 @@ | |||
| 22 | 22 | //! | Forge | yes | one boolean in `user_config` | | |
| 23 | 23 | //! | Display | yes | five booleans, a number and a control | | |
| 24 | 24 | //! | Storage | **no** | library paths, reachability, relocation: the filesystem | | |
| 25 | - | //! | Advanced | **no** | native file dialogs for theme import and export | | |
| 25 | + | //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog | | |
| 26 | 26 | //! | License | **no** | a key exchanged with a server | | |
| 27 | 27 | //! | Trash | **no** | filesystem sizes and a destructive sweep over them | | |
| 28 | 28 | //! | Classifier | **no** | its own model state, and bespoke | | |
| 29 | 29 | //! | |
| 30 | 30 | //! Storage and Trash are the honest kind of "no": they are about files on a | |
| 31 | 31 | //! disk, and a description that named them would be describing this host's | |
| 32 | - | //! filesystem. Advanced is the *interesting* one, and it is the finding | |
| 33 | - | //! goingson's settings port already filed and this port confirms: **a control | |
| 34 | - | //! that asks the host where to put something and then acts has no vocabulary.** | |
| 35 | - | //! `FieldKind::File` covers picking a file to submit; nothing covers "open a | |
| 36 | - | //! save dialog, then write there", which is what Export Theme is. Second | |
| 37 | - | //! consumer, which under the evidence rule is convergence rather than drift. | |
| 32 | + | //! filesystem. | |
| 33 | + | //! | |
| 34 | + | //! **Advanced was the interesting one, and half of it is answered.** This port | |
| 35 | + | //! filed "a control that asks the host where to put something and then acts has | |
| 36 | + | //! no vocabulary" as the second consumer of goingson's finding. Max ruled it on | |
| 37 | + | //! 2026-08-21 (`67881a88`) and the answer was that the premise was wrong: there | |
| 38 | + | //! is no picker in it. `Outcome::File` hands back a name, a kind and the bytes, | |
| 39 | + | //! and **where they land is the host's** -- a save dialog here, the working | |
| 40 | + | //! directory on a terminal, a download in a browser. Export Current is described | |
| 41 | + | //! now and is this host's first consumer of the member; `panel::hand_over` is | |
| 42 | + | //! the host half. | |
| 43 | + | //! | |
| 44 | + | //! Import Theme stays out, and it is a different gap: `FieldKind::File` says | |
| 45 | + | //! what may be picked, and nothing carries the picked file's *bytes* to a sync | |
| 46 | + | //! route on this host. That is host plumbing rather than vocabulary. | |
| 38 | 47 | //! | |
| 39 | 48 | //! # The finding this port adds | |
| 40 | 49 | //! | |
| @@ -89,6 +98,7 @@ | |||
| 89 | 98 | .get("/settings", index) | |
| 90 | 99 | .post("/settings/config/{key}", write) | |
| 91 | 100 | .post("/settings/columns/reset", reset_columns) | |
| 101 | + | .post("/settings/theme/export", export_theme) | |
| 92 | 102 | } | |
| 93 | 103 | ||
| 94 | 104 | /// `GET /settings` | |
| @@ -166,9 +176,62 @@ | |||
| 166 | 176 | )) | |
| 167 | 177 | .with(Node::Field(Box::new(row_height(state)?))); | |
| 168 | 178 | ||
| 179 | + | // Advanced, half of it. See the header: Export Current is describable as of | |
| 180 | + | // quasi 0.50.0 and Import Theme is not, so the section is what the | |
| 181 | + | // vocabulary can say rather than all-or-nothing. | |
| 182 | + | if let Some(active) = active_theme(state) { | |
| 183 | + | body = body | |
| 184 | + | .with(Node::section("Advanced")) | |
| 185 | + | .with(Node::text(format!( | |
| 186 | + | "The theme showing is {}. Exporting writes {}.toml wherever you choose.", | |
| 187 | + | active.name, active.id | |
| 188 | + | ))) | |
| 189 | + | .with(Node::Act(Act::new( | |
| 190 | + | "Export current theme", | |
| 191 | + | Action::post("/settings/theme/export"), | |
| 192 | + | ))); | |
| 193 | + | } | |
| 194 | + | ||
| 169 | 195 | Ok(Screen::sidebar_content("Settings").with(body)) | |
| 170 | 196 | } | |
| 171 | 197 | ||
| 198 | + | /// The theme showing, as the host resolved it. | |
| 199 | + | /// | |
| 200 | + | /// Matched on the stored id rather than on anything the renderer knows, and | |
| 201 | + | /// `None` when nothing is stored or the stored id names a theme that is gone -- | |
| 202 | + | /// in which case there is nothing to export and the section does not appear. | |
| 203 | + | fn active_theme<'a>(state: &'a Panels<'_>) -> Option<&'a super::ThemeChoice> { | |
| 204 | + | let chosen = state.config.get(ConfigKey::Theme).ok().flatten()?; | |
| 205 | + | state | |
| 206 | + | .themes | |
| 207 | + | .iter() | |
| 208 | + | .find(|theme| theme.id == chosen && theme.source.is_some()) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | /// `POST /settings/theme/export` | |
| 212 | + | /// | |
| 213 | + | /// **First consumer of `Outcome::File` on this host** (`67881a88`, ruled | |
| 214 | + | /// 2026-08-21: the route answers with the file and the host puts it somewhere). | |
| 215 | + | /// | |
| 216 | + | /// This is the shape the port's own header called the interesting "no": a | |
| 217 | + | /// control that asks the host where to put something and then acts had no | |
| 218 | + | /// vocabulary, and the answer turned out not to be a picker at all. The route | |
| 219 | + | /// hands over bytes and a suggested name; where they land is the host's. So the | |
| 220 | + | /// description never names a path, and the same act reads correctly on a | |
| 221 | + | /// terminal and in a browser. | |
| 222 | + | fn export_theme(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 223 | + | let active = active_theme(state).ok_or_else(|| RouteError::not_found("no theme to export"))?; | |
| 224 | + | let source = active | |
| 225 | + | .source | |
| 226 | + | .clone() | |
| 227 | + | .ok_or_else(|| RouteError::not_found("that theme has no source to export"))?; | |
| 228 | + | Ok(Response::file( | |
| 229 | + | format!("{}.toml", active.id), | |
| 230 | + | quasi_router::Accepted::suffix(".toml"), | |
| 231 | + | source.into_bytes(), | |
| 232 | + | )) | |
| 233 | + | } | |
| 234 | + | ||
| 172 | 235 | /// The theme picker. | |
| 173 | 236 | /// | |
| 174 | 237 | /// A `Field` and not a `Node::Select`, which is worth saying because the wrong |
| @@ -547,11 +547,15 @@ | |||
| 547 | 547 | id: "audiofiles".into(), | |
| 548 | 548 | name: "audiofiles".into(), | |
| 549 | 549 | variant: "light".into(), | |
| 550 | + | source: Some("[color]\nink = \"#111111\"\n".into()), | |
| 550 | 551 | }, | |
| 552 | + | // No source, which is the built-in-with-nothing-to-read case: Export | |
| 553 | + | // must offer nothing rather than offer an empty file. | |
| 551 | 554 | ThemeChoice { | |
| 552 | 555 | id: "nord".into(), | |
| 553 | 556 | name: "Nord".into(), | |
| 554 | 557 | variant: "dark".into(), | |
| 558 | + | source: None, | |
| 555 | 559 | }, | |
| 556 | 560 | ] | |
| 557 | 561 | } | |
| @@ -643,11 +647,86 @@ | |||
| 643 | 647 | assert!(table.contains(&(Method::Post, "/settings/config/{key}".to_owned()))); | |
| 644 | 648 | // Counted per screen rather than in total, so a second screen landing in the | |
| 645 | 649 | // same table does not read as this one growing routes. | |
| 650 | + | // | |
| 651 | + | // Four, and the two that are not the key/value route are not exceptions to | |
| 652 | + | // it: `columns/reset` is one write to one key, and `theme/export` is not a | |
| 653 | + | // write at all -- it hands back a file. What this asserts is that no control | |
| 654 | + | // grew an address of its own, which is the drift it exists to catch. | |
| 646 | 655 | let settings = table | |
| 647 | 656 | .iter() | |
| 648 | 657 | .filter(|(_, path)| path.starts_with("/settings")) | |
| 649 | 658 | .count(); | |
| 650 | - | assert_eq!(settings, 3, "{table:?}"); | |
| 659 | + | assert_eq!(settings, 4, "{table:?}"); | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | /// A router call against the settings screen, over a given config store. | |
| 663 | + | fn settling(store: &Store, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 664 | + | let themes = themes(); | |
| 665 | + | let sync = Offline; | |
| 666 | + | let files = FakeFiles::default(); | |
| 667 | + | let state = Panels { | |
| 668 | + | detail: &Unfocused, | |
| 669 | + | bulk: &Unchosen, | |
| 670 | + | shell: &Quiet, | |
| 671 | + | library: &Empty, | |
| 672 | + | bar: &Still, | |
| 673 | + | config: store, | |
| 674 | + | sync: &sync, | |
| 675 | + | files: &files, | |
| 676 | + | export: &Idle, | |
| 677 | + | naming: &Unnamed, | |
| 678 | + | importing: &NoImport, | |
| 679 | + | integrity: &Sound, | |
| 680 | + | editor: &Unedited, | |
| 681 | + | forge: &Unforged, | |
| 682 | + | queue: &Unqueued, | |
| 683 | + | filters: &Unfiltered, | |
| 684 | + | themes: &themes, | |
| 685 | + | }; | |
| 686 | + | router().handle(&state, request) | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | #[test] | |
| 690 | + | fn exporting_a_theme_hands_back_the_file_rather_than_naming_a_path() { | |
| 691 | + | // First consumer of `Outcome::File` on this host (`67881a88`). The | |
| 692 | + | // description never names a path: the route answers with the bytes and a | |
| 693 | + | // suggested name, and where they land is the host's -- a save dialog here, | |
| 694 | + | // the working directory on a terminal, a download in a browser. | |
| 695 | + | let store = Store::with(&[(ConfigKey::Theme, "audiofiles")]); | |
| 696 | + | let response = settling(&store, Request::post("/settings/theme/export")) | |
| 697 | + | .expect("the active theme has a source, so it exports"); | |
| 698 | + | ||
| 699 | + | let Outcome::File { name, kind, bytes } = &response.outcome else { | |
| 700 | + | panic!("expected a file, got {:?}", response.outcome); | |
| 701 | + | }; | |
| 702 | + | assert_eq!(name, "audiofiles.toml"); | |
| 703 | + | assert_eq!(kind, &quasi_router::Accepted::suffix(".toml")); | |
| 704 | + | assert!(String::from_utf8_lossy(bytes).contains("[color]")); | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | #[test] | |
| 708 | + | fn a_theme_with_no_readable_source_is_not_offered_for_export() { | |
| 709 | + | // `nord` in the fixture has `source: None`, which is a built-in with nothing | |
| 710 | + | // to read. Offering Export on it would hand the user an empty file. | |
| 711 | + | let store = Store::with(&[(ConfigKey::Theme, "nord")]); | |
| 712 | + | assert!( | |
| 713 | + | settling(&store, Request::post("/settings/theme/export")).is_err(), | |
| 714 | + | "a theme with no source was exported anyway" | |
| 715 | + | ); | |
| 716 | + | ||
| 717 | + | let screen = match settling(&store, Request::get("/settings")) | |
| 718 | + | .expect("settings answers") | |
| 719 | + | .outcome | |
| 720 | + | { | |
| 721 | + | Outcome::Screen(screen) => screen, | |
| 722 | + | other => panic!("expected a screen, got {other:?}"), | |
| 723 | + | }; | |
| 724 | + | assert!( | |
| 725 | + | !acts(&screen) | |
| 726 | + | .iter() | |
| 727 | + | .any(|label| label == "Export current theme"), | |
| 728 | + | "the act is on the screen for a theme that cannot answer it" | |
| 729 | + | ); | |
| 651 | 730 | } | |
| 652 | 731 | ||
| 653 | 732 | #[test] |