Skip to main content

max / audiofiles

Draw the described settings screen beside the shipped one Behind the same feature, on the same toggle: opening Settings with `--features quasi` opens both windows, so the port can be compared by looking at it rather than by reading its tests. Replacing the working panel on the way in would have meant being right first time. The host half is small on purpose and does four things, none of them drawing: resolve the facts the screen needs (themes, palette), hold the Runtime across frames because a frame does not outlive itself, hand a Step to the router and the answer back, and put a route failure somewhere the user can see. There is no `if let Node::` in it and there should never be one -- that is the line where a port becomes a second renderer. Two things the wiring forced, both worth the note they carry: The runtime is taken out of BrowserState for the frame, because the router borrows the backend off the same struct. `&dyn Backend` does not coerce to `&dyn Config`: they are unrelated trait objects and Rust upcasts between neither, so the blanket impl over Backend could not be reached through the app's boxed handle. It is a named `FromBackend` adapter now, which is better anyway -- one type that says where the app's handle becomes the narrow thing the screens borrow, and the only place Backend is named on this side of the boundary.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 21:33 UTC
Signed with PGP, not checked
Commit: e2bb987d55d843525c26b1288d6713211fa10d24
Parent: 494a5bd
5 files changed, +202 insertions, -18 deletions
M Cargo.lock +12 -13
@@ -4243,7 +4243,6 @@
4243 4243 [[package]]
4244 4244 name = "quasi-immediate"
4245 4245 version = "0.1.0"
4246 - source = "git+https://makenot.work/git/max/quasi.git#34f5145445f0351eb78984f97030918594bacf16"
4247 4246 dependencies = [
4248 4247 "docengine",
4249 4248 "egui",
@@ -7542,6 +7541,18 @@
7542 7541 "winnow 1.0.4",
7543 7542 ]
7544 7543
7544 + [[patch.unused]]
7545 + name = "kberg"
7546 + version = "0.1.0"
7547 +
7548 + [[patch.unused]]
7549 + name = "ops-status"
7550 + version = "0.1.0"
7551 +
7552 + [[patch.unused]]
7553 + name = "painhours"
7554 + version = "0.1.0"
7555 +
7545 7556 [[patch.unused]]
7546 7557 name = "quasi-axum"
7547 7558 version = "0.2.0"
@@ -7561,15 +7572,3 @@
7561 7572 [[patch.unused]]
7562 7573 name = "quasi-webview"
7563 7574 version = "0.2.0"
7564 -
7565 - [[patch.unused]]
7566 - name = "kberg"
7567 - version = "0.1.0"
7568 -
7569 - [[patch.unused]]
7570 - name = "ops-status"
7571 - version = "0.1.0"
7572 -
7573 - [[patch.unused]]
7574 - name = "painhours"
7575 - version = "0.1.0"
@@ -163,6 +163,11 @@
163 163 // Settings window
164 164 if state.settings.show_manager {
165 165 crate::ui::settings_panel::draw_settings_panel(ctx, state);
166 + // The described one beside it, on the same toggle, so the two can be
167 + // compared by looking at them. Behind the feature, so a shipped build
168 + // has neither this call nor the module it reaches.
169 + #[cfg(feature = "quasi")]
170 + crate::quasi::panel::draw(ctx, state);
166 171 }
167 172
168 173 // Sync panel overlay
@@ -31,6 +31,7 @@
31 31 // a choice made here.
32 32 #![allow(clippy::needless_pass_by_value)]
33 33
34 + pub mod panel;
34 35 pub mod settings;
35 36
36 37 use audiofiles_core::config_key::ConfigKey;
@@ -53,8 +54,10 @@
53 54 /// - **The error stops being the backend's.** A route answers `RouteError`, so
54 55 /// the store's failure is flattened to a string here and classified there.
55 56 ///
56 - /// Blanket-implemented for every `Backend`, so the app hands over the handle it
57 - /// already has and nothing is wired twice.
57 + /// Adapted from the app's own handle by [`FromBackend`], which is one named
58 + /// conversion rather than a blanket impl: `&dyn Backend` and `&dyn Config` are
59 + /// unrelated trait objects and Rust upcasts between neither, so the adaptation
60 + /// has to be spelled somewhere. Spelling it as a type says where.
58 61 pub trait Config {
59 62 /// What is stored under this key, if anything is.
60 63 ///
@@ -69,13 +72,20 @@
69 72 fn set(&self, key: ConfigKey, value: &str) -> Result<(), String>;
70 73 }
71 74
72 - impl<T: Backend + ?Sized> Config for T {
75 + /// The app's backend, as the narrow thing a described screen borrows.
76 + ///
77 + /// The whole of the adaptation, and the only place `Backend` is named on this
78 + /// side of the boundary.
79 + pub struct FromBackend<'a>(pub &'a dyn Backend);
80 +
81 + impl Config for FromBackend<'_> {
73 82 fn get(&self, key: ConfigKey) -> Result<Option<String>, String> {
74 - self.get_config(key).map_err(|error| error.to_string())
83 + self.0.get_config(key).map_err(|error| error.to_string())
75 84 }
76 85
77 86 fn set(&self, key: ConfigKey, value: &str) -> Result<(), String> {
78 - self.set_config(key, value)
87 + self.0
88 + .set_config(key, value)
79 89 .map_err(|error| error.to_string())
80 90 }
81 91 }
@@ -259,6 +259,14 @@
259 259
260 260 // Settings (consolidated window)
261 261 pub settings: SettingsUiState,
262 + /// The described settings screen, behind the `quasi` feature.
263 + ///
264 + /// Its own state rather than a field on `SettingsUiState`: the described
265 + /// screen holds what was typed into it, and the shipped panel holds its own
266 + /// separately. Two windows, two states, no sharing, which is what lets them
267 + /// be compared.
268 + #[cfg(feature = "quasi")]
269 + pub described: crate::quasi::panel::Described,
262 270
263 271 /// Tag-classifier UI state (rules builder in Settings).
264 272 pub classifier: ClassifierUiState,
@@ -439,6 +447,8 @@
439 447 == Some("1");
440 448
441 449 Ok(Self {
450 + #[cfg(feature = "quasi")]
451 + described: crate::quasi::panel::Described::default(),
442 452 data_dir: data_dir.to_path_buf(),
443 453 backend,
444 454 nav: NavUiState {
@@ -1,0 +1,160 @@
1 + //! The described settings screen, in a window beside the shipped one.
2 + //!
3 + //! Beside rather than instead of, which is the whole arrangement: with the
4 + //! feature on, opening Settings opens both windows, and the two can be compared
5 + //! by looking at them. A port that replaced the working panel on the way in
6 + //! would have to be right first time.
7 + //!
8 + //! # What this module is, and what it deliberately is not
9 + //!
10 + //! It is the *host* half of the port, and it is small on purpose: fifty lines of
11 + //! plumbing against a described screen that knows nothing about egui. Everything
12 + //! it does is one of four things, and none of them is drawing:
13 + //!
14 + //! 1. resolve the host facts the screen needs ([`ThemeChoice`], the palette),
15 + //! 2. hold the [`Runtime`] across frames, because a frame does not outlive itself,
16 + //! 3. hand a [`Step`] to the router and the answer back to the runtime,
17 + //! 4. put a route failure somewhere the user can see it.
18 + //!
19 + //! There is no `if let Node::...` anywhere here, and there should never be one.
20 + //! The moment this file starts deciding what a node looks like, the drawing has
21 + //! left `quasi-immediate` and the port has become a second renderer.
22 +
23 + use quasi_immediate::{Immediate, Runtime, Step};
24 + use quasi_router::Request;
25 +
26 + use super::{Settings, ThemeChoice};
27 + use crate::state::BrowserState;
28 + use crate::ui::theme;
29 +
30 + /// The described screen's own state, held across frames.
31 + ///
32 + /// A `Runtime` and nothing else. What the user typed and ticked lives inside it,
33 + /// which is the half egui does not hold for a described screen: the fields are
34 + /// rebuilt from the description every frame, so their buffers have to outlive
35 + /// one.
36 + #[derive(Debug, Default)]
37 + pub struct Described {
38 + runtime: Option<Runtime>,
39 + }
40 +
41 + impl Described {
42 + /// Forget the screen, so the next open reads the store again.
43 + ///
44 + /// Called when the window closes rather than on every frame: the runtime is
45 + /// what holds unsent edits, and rebuilding it per frame would throw away
46 + /// what the user was typing.
47 + pub fn close(&mut self) {
48 + self.runtime = None;
49 + }
50 + }
51 +
52 + /// Draw the described settings window, and act on whatever was pressed.
53 + pub fn draw(ctx: &egui::Context, state: &mut BrowserState) {
54 + // Taken out of the state for the frame, because the router borrows the
55 + // backend off the same struct. Put back below whatever happens.
56 + let mut runtime = state.described.runtime.take();
57 + let themes = themes();
58 +
59 + let mut open = true;
60 + egui::Window::new("Settings (described)")
61 + .open(&mut open)
62 + .default_width(420.0)
63 + .show(ctx, |ui| {
64 + let immediate = Immediate::new(theme::palette());
65 +
66 + // The first frame has no screen yet, so it asks for one. Everything
67 + // after it is the loop below.
68 + let runtime = match &mut runtime {
69 + Some(runtime) => runtime,
70 + none => match answer(state, &themes, Request::get("/settings")) {
71 + Ok(screen) => none.insert(Runtime::new(screen)),
72 + Err(message) => {
73 + ui.label(message);
74 + return;
75 + }
76 + },
77 + };
78 +
79 + match runtime.show(ui, &immediate) {
80 + Step::Idle => {}
81 + Step::Call(request) => match answer(state, &themes, request.clone()) {
82 + Ok(screen) => {
83 + runtime.apply(&request, quasi_router::Response::screen(screen));
84 + }
85 + Err(message) => runtime.say(message),
86 + },
87 + // A described control asked before acting. The shipped panel has
88 + // no equivalent, so this is drawn where it is asked rather than
89 + // in a second window.
90 + Step::Ask(question) => {
91 + ui.label(&question);
92 + ui.horizontal(|ui| {
93 + if ui.button("Yes").clicked() {
94 + let step = runtime.answer(true);
95 + if let Step::Call(request) = step {
96 + match answer(state, &themes, request.clone()) {
97 + Ok(screen) => {
98 + runtime.apply(
99 + &request,
100 + quasi_router::Response::screen(screen),
101 + );
102 + }
103 + Err(message) => runtime.say(message),
104 + }
105 + }
106 + }
107 + if ui.button("No").clicked() {
108 + runtime.answer(false);
109 + }
110 + });
111 + }
112 + // Nothing here addresses anything outside the app yet. Said
113 + // rather than ignored, so the first control that does is a
114 + // message and not a silence.
115 + Step::Open(address) => runtime.say(format!("Nothing here opens {address}.")),
116 + }
117 + });
118 +
119 + state.described.runtime = runtime;
120 + if !open {
121 + state.settings.show_manager = false;
122 + state.described.close();
123 + }
124 + }
125 +
126 + /// Ask the router, and flatten a refusal into something a user can read.
127 + ///
128 + /// The one place the app's own error vocabulary and the router's meet. A
129 + /// `RouteError` carries a class and a message; a window has room for the
130 + /// message.
131 + fn answer(
132 + state: &BrowserState,
133 + themes: &[ThemeChoice],
134 + request: Request,
135 + ) -> Result<quasi_router::Screen, String> {
136 + let config = super::FromBackend(&*state.backend);
137 + let settings = Settings {
138 + config: &config,
139 + themes,
140 + };
141 + let response = super::router()
142 + .handle(&settings, request)
143 + .map_err(|error| error.message.clone())?;
144 + match response.outcome {
145 + quasi_router::Outcome::Screen(screen) => Ok(screen),
146 + other => Err(format!("the settings routes answered {other:?}")),
147 + }
148 + }
149 +
150 + /// The themes the host has resolved, as the description names them.
151 + fn themes() -> Vec<ThemeChoice> {
152 + theme::list_themes()
153 + .into_iter()
154 + .map(|meta| ThemeChoice {
155 + id: meta.id,
156 + name: meta.name,
157 + variant: meta.variant,
158 + })
159 + .collect()
160 + }