Skip to main content

max / audiofiles

Take Outcome::File on the egui host, and describe Export Theme with it quasi ruled the save-destination gap on 2026-08-21 (67881a88) and shipped Outcome::File in 0.50.0. Nothing on this side ever drained it, so the member read as one that does not work here and a described control producing a file was unbuildable. panel::hand_over is the host half, drained once in window() rather than per draw_*: a file is produced by a route and every described window can reach one. Accepted::Suffix becomes a native dialog filter; a family or a media type is a fact about the file rather than a list of extensions, so those offer no filter and the user picks freely. Settings/Advanced was one of the five sections this port recorded as not describable, and half of it stops being so. The finding said "a control that asks the host where to put something and then acts has no vocabulary"; the ruling says the premise was wrong, there is no picker in it. The route hands back audiofiles.toml, its kind and its bytes, and the description never names a path. Import Theme stays out and is a different gap: nothing carries a picked file's bytes to a sync route on this host, which is plumbing rather than vocabulary. ThemeChoice carries the theme's source, on the settled rule that a host fact the app can answer goes in S rather than behind a capability -- a route reading a file would be the thing the narrow traits exist to prevent. None for a theme with nothing readable, and Export is not offered for one rather than offering an empty file. 615 tests green with --features quasi.
Author: Max Johnson <me@maxj.phd> · 2026-08-22 03:58 UTC
Signed with PGP, not checked
Commit: f0ae5c46c4b94b7b51f4c9134138d94fceb23c89
Parent: bbf06e8
5 files changed, +240 insertions, -12 deletions
M Cargo.lock +4 -4
@@ -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]