Skip to main content

max / audiofiles

Say the help overlay's tabs as a region showing one child quasicoherent e9f61b46: one way to describe a tab strip. This overlay held the last Node::Select in the tree, and the member is retired in quasi 0.82.0. The strip is derived from the children's labels now, and which tab is up is Slot::showing_one. POST /help/tab goes with it. Both panels are static documents built here, so a round trip to reveal bytes the reader already has is ceremony -- the same reading every renderer's own derivation makes about a carousel's frames. Switching tabs is local in all three hosts. The parity walk loses its Select arm, and the doc comments across bulk, settings, forge and export that explained themselves as "a Field and not a Node::Select" now explain themselves against what is there.
Author: Max Johnson <me@maxj.phd> · 2026-08-30 19:22 UTC
Signed with PGP, not checked
Commit: 93988096bc2be33fd933ffa4da887ac7db06250b
Parent: 2fd087a
9 files changed, +132 insertions, -148 deletions
M Cargo.lock +12 -12
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.81.0"
4259 + version = "0.82.0"
4260 4260 dependencies = [
4261 4261 "docengine",
4262 4262 "egui",
@@ -4267,7 +4267,7 @@
4267 4267
4268 4268 [[package]]
4269 4269 name = "quasi-router"
4270 - version = "0.81.0"
4270 + version = "0.82.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -7558,19 +7558,19 @@
7558 7558
7559 7559 [[patch.unused]]
7560 7560 name = "quasi-axum"
7561 - version = "0.81.0"
7561 + version = "0.82.0"
7562 7562
7563 7563 [[patch.unused]]
7564 7564 name = "quasi-basics"
7565 - version = "0.81.0"
7565 + version = "0.82.0"
7566 7566
7567 7567 [[patch.unused]]
7568 7568 name = "quasi-http"
7569 - version = "0.81.0"
7569 + version = "0.82.0"
7570 7570
7571 7571 [[patch.unused]]
7572 7572 name = "quasi-notifs"
7573 - version = "0.81.0"
7573 + version = "0.82.0"
7574 7574
7575 7575 [[patch.unused]]
7576 7576 name = "quasi-store"
@@ -7578,15 +7578,11 @@
7578 7578
7579 7579 [[patch.unused]]
7580 7580 name = "quasi-tauri"
7581 - version = "0.81.0"
7581 + version = "0.82.0"
7582 7582
7583 7583 [[patch.unused]]
7584 7584 name = "quasi-webview"
7585 - version = "0.81.0"
7586 -
7587 - [[patch.unused]]
7588 - name = "quasi-type"
7589 - version = "0.1.3"
7585 + version = "0.82.0"
7590 7586
7591 7587 [[patch.unused]]
7592 7588 name = "kberg"
@@ -7599,3 +7595,7 @@
7599 7595 [[patch.unused]]
7600 7596 name = "painhours"
7601 7597 version = "0.1.0"
7598 +
7599 + [[patch.unused]]
7600 + name = "quasi-type"
7601 + version = "0.1.3"
M Cargo.toml +2 -2
@@ -26,8 +26,8 @@
26 26 # The described screens, which are audiofiles-browser's screens. By git URL with
27 27 # a version requirement, per the tree's rule for cross-repo deps. They were
28 28 # behind a `quasi` feature until 2026-08-25, when the last flip landed.
29 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.81" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.81" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.82" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.82" }
31 31 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
32 32 egui_extras = { version = "0.35", default-features = false }
33 33 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -199,9 +199,9 @@
199 199 // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a
200 200 // segmented control spelled out.
201 201 //
202 - // A field in the form rather than a `Node::Select` beside it, which is what
203 - // it always was: nothing writes when it changes, and `tag` reads it out of
204 - // the payload at submit alongside the tag itself.
202 + // A field in the form rather than a control beside it, which is what it
203 + // always was: nothing writes when it changes, and `tag` reads it out of the
204 + // payload at submit alongside the tag itself.
205 205 let mode = Field::radio(
206 206 MODE,
207 207 "Mode",
@@ -550,9 +550,8 @@
550 550
551 551 /// The device profile picker.
552 552 ///
553 - /// A `Field` rather than a `Node::Select` for `settings.rs`'s reason: the choice
554 - /// count is open — profiles are plugins — so it has to be able to fold away, and
555 - /// that is a dropdown.
553 + /// A dropdown for `settings.rs`'s reason: the choice count is open — profiles
554 + /// are plugins — so it has to be able to fold away.
556 555 fn profile_field(profiles: &[ProfileChoice], chosen: Option<&str>) -> Node {
557 556 let mut options = vec![Choice::new(String::new(), "None (manual)")];
558 557 options.extend(profiles.iter().map(|profile| {
@@ -661,11 +660,9 @@
661 660
662 661 /// One of a handful of choices, asked as a question.
663 662 ///
664 - /// A `Field` and not a `Node::Select`, which is the line the sweep drew: every
665 - /// one of these is a form question wearing a strip -- pick exactly one of a
666 - /// handful, write immediately, no submit -- and only a field has a label, a
667 - /// hint, an error and a note to say it with. `Node::Select` carries a
668 - /// destination per option, which none of these ever used.
663 + /// Every one of these is a form question wearing a strip -- pick exactly one of
664 + /// a handful, write immediately, no submit -- and a field is what has a label, a
665 + /// hint, an error and a note to say it with.
669 666 ///
670 667 /// Returns the `Field` rather than a `Node` so a caller can say what the
671 668 /// answer costs before wrapping it; see [`format_field`].
@@ -429,10 +429,9 @@
429 429
430 430 /// A handful of values that do not fold away, asked as a question.
431 431 ///
432 - /// A `Field` and not a `Node::Select`: five slice counts and three subdivisions
433 - /// are one choice each, written immediately, with no destination per option.
434 - /// Being a field is what gives them a label, which as a strip they had to
435 - /// borrow from a section heading above.
432 + /// Five slice counts and three subdivisions are one choice each, written
433 + /// immediately. Being a field is what gives them a label; a bare strip of
434 + /// options had to borrow one from a section heading above.
436 435 fn strip(
437 436 knob: Knob,
438 437 label: &str,
@@ -98,29 +98,24 @@
98 98 //! take. Untidy rather than broken, and it is what shipped: the binding
99 99 //! predates this flip, which only made it the sole path to the overlay.
100 100
101 - use quasi_router::layout::Selector;
102 101 use quasi_router::{
103 - Action, Cell, Cells, Choice, Chrome, Column, Node, Outcome, RegionKind, Request, Response,
104 - RouteError, Router, Screen, Slot,
102 + Action, Cell, Cells, Chrome, Column, Node, RegionKind, Request, Response, RouteError, Router,
103 + Screen, Slot,
105 104 };
106 105
107 106 use super::{Panel, Panels};
108 107
109 108 /// The region the overlay answers into.
110 109 const BODY: &str = "help-body";
111 - /// The region a tab's contents land in.
110 + /// The region that holds the tabs and shows one of them.
112 111 const TAB: &str = "help-tab";
113 - /// The region the grouped key tables sit in.
112 + /// One tab: the grouped key tables.
114 113 ///
115 - /// A region rather than a bare run because a tab's contents are one node, and
116 - /// grouping turned this tab from one table into a heading and a table per
117 - /// group.
118 - const SHORTCUTS_BODY: &str = "help-shortcuts";
119 -
120 - /// Which tab is showing.
121 - const SHORTCUTS: &str = "shortcuts";
114 + /// A region rather than a bare run because grouping turned this tab from one
115 + /// table into a heading and a table per group, and a tab is one child.
116 + const SHORTCUTS: &str = "help-shortcuts";
122 117 /// The other one.
123 - const FEATURES: &str = "features";
118 + const FEATURES: &str = "help-features";
124 119
125 120 /// The keys that work from every described screen.
126 121 ///
@@ -236,67 +231,63 @@
236 231 Action::post(format!("/panels/{}", panel.as_str()))
237 232 }
238 233
239 - /// Register the help overlay's routes.
234 + /// Register the help overlay's route.
235 + ///
236 + /// One of them. Switching tabs was `POST /help/tab` until the strip became a
237 + /// region showing one child at a time; see [`screen`] for why both panels now
238 + /// arrive with the overlay.
240 239 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
241 - router.get("/help", index).post("/help/tab", tab)
240 + router.get("/help", index)
242 241 }
243 242
244 243 /// `GET /help`
245 244 fn index(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
246 - Ok(Response::over(screen(SHORTCUTS)))
247 - }
248 -
249 - /// `POST /help/tab`
250 - ///
251 - /// A fragment, because this overlay is already open. See the header: neither
252 - /// outcome that carries a whole screen can replace one layer of a stack.
253 - fn tab(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
254 - let chosen = request.payload.get(Node::SELECTED).unwrap_or(SHORTCUTS);
255 - if chosen != SHORTCUTS && chosen != FEATURES {
256 - return Err(RouteError::not_found("no such tab"));
257 - }
258 - Ok(Response::from(Outcome::Fragment {
259 - region: TAB.to_owned(),
260 - node: showing(chosen),
261 - }))
245 + Ok(Response::over(screen()))
262 246 }
263 247
264 248 /// The overlay.
265 - fn screen(chosen: &str) -> Screen {
249 + ///
250 + /// # Two tabs as one region, not as a control beside one
251 + ///
252 + /// This was a `Node::Select` over a `Slot` the chosen tab's contents were
253 + /// posted into, and it was the last tenant of that member anywhere in the tree
254 + /// (`e9f61b46`, Max: one way to describe a tab strip). A region showing one
255 + /// child at a time says the same thing with nothing beside it: the strip is
256 + /// derived from the children's labels, and which one is up is
257 + /// [`Slot::showing_one`].
258 + ///
259 + /// Both panels are built here rather than fetched, so no child carries
260 + /// [`Slot::fed_by`] and pressing a tab calls no route. They are two static
261 + /// documents a few kilobytes long; a round trip to reveal bytes already in hand
262 + /// is what every renderer's derivation refuses. That is what retired
263 + /// `POST /help/tab` along with the member.
264 + fn screen() -> Screen {
266 265 let body = Slot::new(BODY, RegionKind::Pane)
267 266 .with(Node::page("audiofiles"))
268 - .with(Node::Select {
269 - kind: Selector::Tabs,
270 - options: vec![
271 - (Choice::new(SHORTCUTS, "Shortcuts"), None),
272 - (Choice::new(FEATURES, "Features"), None),
273 - ],
274 - chosen: Some(chosen.to_owned()),
275 - action: Some(Action::post("/help/tab")),
276 - })
277 267 .with(Node::Region(
278 - Slot::new(TAB, RegionKind::Group).with(showing(chosen)),
268 + Slot::new(TAB, RegionKind::TabGroup)
269 + .showing_one(0)
270 + .with(Node::Region(shortcuts().into_iter().fold(
271 + Slot::new(SHORTCUTS, RegionKind::Group).label("Shortcuts"),
272 + Slot::with,
273 + )))
274 + .with(Node::Region(
275 + Slot::new(FEATURES, RegionKind::Group)
276 + .label("Features")
277 + .with(features()),
278 + )),
279 279 ));
280 280
281 281 Screen::sidebar_content("Help").with(body)
282 282 }
283 283
284 - /// Whichever tab is chosen.
285 - fn showing(chosen: &str) -> Node {
286 - if chosen == FEATURES {
287 - features()
288 - } else {
289 - shortcuts()
290 - }
291 - }
292 -
293 284 /// Every key that works, read off the one table.
294 285 ///
295 286 /// No filter box. The shipped tab has one because twenty-six rows in a
296 287 /// fixed-height scroll area need it; narrowing a list a screen was handed is
297 288 /// what a host does, which is the rule the bulk port's tag completions and
298 289 /// folder filter both follow.
299 - fn shortcuts() -> Node {
290 + fn shortcuts() -> Vec<Node> {
300 291 // A heading and a table per group, in the order the app bound them, which
301 292 // is what `Chrome::grouped` answers. Not sorted here: the reading order is
302 293 // the shipped tab's and belongs to whoever wrote the table.
@@ -327,11 +318,7 @@
327 318 more: None,
328 319 });
329 320 }
330 - let mut body = Slot::new(SHORTCUTS_BODY, RegionKind::Group);
331 - for node in sections {
332 - body = body.with(node);
333 - }
334 - Node::Region(body)
321 + sections
335 322 }
336 323
337 324 /// What the app does, in prose.
@@ -294,29 +294,6 @@
294 294 field_offers(field, out);
295 295 }
296 296 }
297 - Node::Select {
298 - options, action, ..
299 - } => {
300 - // A segmented control is one choice with several labels on the
301 - // shipped side too, so each option is an offer rather than the
302 - // strip being one.
303 - //
304 - // A `Button` and not a `Choice`, which reads backwards until you
305 - // ask what a renderer draws: every host draws a `Node::Select` as a
306 - // strip of pressable segments, and egui reports a selectable label
307 - // as `Role::Button` with nothing to distinguish it from an ordinary
308 - // one. `Choice` stays for `FieldKind::Select` and `Radio`, which are
309 - // a box you open and a set of radios -- genuinely different things
310 - // to operate. Corrected 2026-08-22, when the forge's eight slice
311 - // options came back as buttons from the renderer and choices from
312 - // here.
313 - for (choice, own) in options {
314 - out.push(Role::Button, choice.label.clone(), false);
315 - if let Some(action) = own.as_ref().or(action.as_ref()) {
316 - out.addresses.push(action.destination.as_str().to_owned());
317 - }
318 - }
319 - }
320 297 Node::Table { columns, rows, .. } => {
321 298 for column in columns {
322 299 // A heading with no address is a heading. Only a sortable one
@@ -297,11 +297,9 @@
297 297
298 298 /// The theme picker.
299 299 ///
300 - /// A `Field` and not a `Node::Select`, which is worth saying because the wrong
301 - /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of
302 - /// a handful of choices, and a theme picker is thirty-odd options that has to
303 - /// collapse. That is a dropdown, and as of makeover-layout 0.38.0 it is
304 - /// `FieldKind::Theme` rather than `FieldKind::Select`.
300 + /// `FieldKind::Theme` and not `FieldKind::Select`, as of makeover-layout
301 + /// 0.38.0. Thirty-odd options that have to collapse and carry a swatch each is
302 + /// not the dropdown a plain choice gets.
305 303 ///
306 304 /// # The finding in the module header is closed
307 305 ///
@@ -1065,8 +1065,7 @@
1065 1065 .handle(&state, Request::get("/settings"))
1066 1066 .expect("answered");
1067 1067
1068 - // A field and not a `Node::Select`: `Selector` is a strip of a handful of
1069 - // choices, and thirty-odd themes that fold away is a dropdown.
1068 + // Thirty-odd themes that fold away is a dropdown, not a strip.
1070 1069 let picker = screen_of(&response)
1071 1070 .slots
1072 1071 .iter()
@@ -4318,47 +4317,74 @@
4318 4317 assert!(matches!(response.outcome, Outcome::Over(_)));
4319 4318 }
4320 4319
4321 - #[test]
4322 - fn switching_tabs_answers_a_fragment_so_the_overlay_survives() {
4323 - // Second consumer of the overlay-refresh finding, and a sharper one than
4324 - // the rename preview: a tabbed overlay is not buildable at all without
4325 - // fragments, because both outcomes that carry a screen destroy the layer.
4326 - let response = helping(
4327 - Request::post("/help/tab")
4328 - .sending(Params::new().with(Node::SELECTED.to_owned(), "features".to_owned())),
4329 - )
4330 - .unwrap();
4331 -
4332 - let Outcome::Fragment { region, node } = &response.outcome else {
4333 - panic!("expected a fragment, got {:?}", response.outcome);
4334 - };
4335 - assert_eq!(region, "help-tab");
4336 - // The features tab is a document, so it is markdown source rather than a
4337 - // tree of headings the description would have to invent structure for.
4338 - assert!(matches!(node, Node::Rich { .. }));
4320 + /// The region holding the overlay's two tabs.
4321 + ///
4322 + /// There is exactly one region showing a single child on this screen, which is
4323 + /// what these tests address it by rather than by its id: the id is a fragment
4324 + /// address and nothing here sends a fragment any more.
4325 + fn tab_strip(screen: &Screen) -> &quasi_router::Slot {
4326 + fn find(slot: &quasi_router::Slot) -> Option<&quasi_router::Slot> {
4327 + if slot.showing.selective() {
4328 + return Some(slot);
4329 + }
4330 + slot.body.iter().find_map(|placed| match &placed.node {
4331 + Node::Region(inner) => find(inner),
4332 + _ => None,
4333 + })
4334 + }
4335 + screen
4336 + .slots
4337 + .iter()
4338 + .find_map(find)
4339 + .expect("the help overlay has no region showing one child at a time")
4339 4340 }
4340 4341
4341 4342 #[test]
4342 - fn a_tab_that_is_not_one_of_the_two_is_refused() {
4343 - assert!(
4344 - helping(
4345 - Request::post("/help/tab")
4346 - .sending(Params::new().with(Node::SELECTED.to_owned(), "elsewhere".to_owned())),
4347 - )
4348 - .is_err()
4349 - );
4343 + fn switching_tabs_asks_the_app_nothing_because_both_tabs_already_arrived() {
4344 + // This was `POST /help/tab` answering a fragment, and the route is gone:
4345 + // both panels are two static documents built here, so a round trip to
4346 + // reveal bytes the reader already has is ceremony. The overlay-refresh
4347 + // finding it stood as the second consumer of is still checked by the rename
4348 + // preview, which is a fragment over a layer for a reason that has not
4349 + // changed -- it is showing something the app computed.
4350 + let response = helping(Request::get("/help")).unwrap();
4351 + let strip = tab_strip(overlay(&response));
4352 +
4353 + assert_eq!(strip.labels(), vec!["Shortcuts", "Features"]);
4354 + for placed in &strip.body {
4355 + let Node::Region(panel) = &placed.node else {
4356 + panic!("a tab is not a region: {:?}", placed.node);
4357 + };
4358 + assert!(
4359 + panel.fed_by.is_none(),
4360 + "a tab whose contents are already here named a route to fetch them"
4361 + );
4362 + }
4350 4363 }
4351 4364
4352 4365 #[test]
4353 4366 fn the_shortcuts_tab_is_what_a_bare_help_request_answers() {
4354 4367 let response = helping(Request::get("/help")).unwrap();
4355 - let screen = overlay(&response);
4368 + let strip = tab_strip(overlay(&response));
4356 4369
4357 - let chosen = nodes(screen).iter().find_map(|node| match node {
4358 - Node::Select { chosen, .. } => chosen.clone(),
4359 - _ => None,
4360 - });
4361 - assert_eq!(chosen.as_deref(), Some("shortcuts"));
4370 + assert_eq!(strip.current(), Some(0));
4371 + assert_eq!(strip.labels().first().copied(), Some("Shortcuts"));
4372 + }
4373 +
4374 + #[test]
4375 + fn the_features_tab_is_the_document_it_says_it_is() {
4376 + // The features tab is a document, so it is markdown source rather than a
4377 + // tree of headings the description would have to invent structure for.
4378 + let response = helping(Request::get("/help")).unwrap();
4379 + let strip = tab_strip(overlay(&response));
4380 +
4381 + let Node::Region(features) = &strip.body[1].node else {
4382 + panic!("the second tab is not a region");
4383 + };
4384 + assert!(matches!(
4385 + features.body.first().map(|placed| &placed.node),
4386 + Some(Node::Rich { .. })
4387 + ));
4362 4388 }
4363 4389
4364 4390 /// A row's primary part.