Skip to main content

max / audiofiles

The tag sidebar is an outline, not a wall of dotted paths quasicoherent `ccaa7e4b`, first consumer. `Row::depth` and `Row::open` landed in quasi 0.79.0, so the shipped sidebar's tag tree is sayable: one row per segment, each carrying its indent, a disclosure on every segment that holds another, and the parent/leaf distinction said in the vocabulary -- a segment that only groups carries no tick, which is the shipped inert label rather than a control that filters to nothing. The filter is a tick rather than a chip, because `required_tags` holds exact paths and ticking a tag is the write. Which branches are open after the first frame stays the renderer's, exactly as egui already held it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0153yKAYUmoC9bZz2eV6cA88
Author: Max Johnson <me@maxj.phd> · 2026-08-29 16:51 UTC
Signed with PGP, not checked
Commit: bb54a487e209778df0dc2fbfeae56d495e04e8b3
Parent: 7c97561
4 files changed, +179 insertions, -91 deletions
M Cargo.lock +34 -34
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.78.0"
4259 + version = "0.79.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.78.0"
4270 + version = "0.79.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -7556,38 +7556,6 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 - [[patch.unused]]
7560 - name = "quasi-type"
7561 - version = "0.1.2"
7562 -
7563 - [[patch.unused]]
7564 - name = "quasi-axum"
7565 - version = "0.78.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "quasi-basics"
7569 - version = "0.78.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-http"
7573 - version = "0.78.0"
7574 -
7575 - [[patch.unused]]
7576 - name = "quasi-notifs"
7577 - version = "0.78.0"
7578 -
7579 - [[patch.unused]]
7580 - name = "quasi-store"
7581 - version = "0.1.0"
7582 -
7583 - [[patch.unused]]
7584 - name = "quasi-tauri"
7585 - version = "0.78.0"
7586 -
7587 - [[patch.unused]]
7588 - name = "quasi-webview"
7589 - version = "0.78.0"
7590 -
7591 7559 [[patch.unused]]
7592 7560 name = "kberg"
7593 7561 version = "0.1.0"
@@ -7599,3 +7567,35 @@
7599 7567 [[patch.unused]]
7600 7568 name = "painhours"
7601 7569 version = "0.1.0"
7570 +
7571 + [[patch.unused]]
7572 + name = "quasi-type"
7573 + version = "0.1.2"
7574 +
7575 + [[patch.unused]]
7576 + name = "quasi-axum"
7577 + version = "0.79.0"
7578 +
7579 + [[patch.unused]]
7580 + name = "quasi-basics"
7581 + version = "0.79.0"
7582 +
7583 + [[patch.unused]]
7584 + name = "quasi-http"
7585 + version = "0.79.0"
7586 +
7587 + [[patch.unused]]
7588 + name = "quasi-notifs"
7589 + version = "0.79.0"
7590 +
7591 + [[patch.unused]]
7592 + name = "quasi-store"
7593 + version = "0.1.0"
7594 +
7595 + [[patch.unused]]
7596 + name = "quasi-tauri"
7597 + version = "0.79.0"
7598 +
7599 + [[patch.unused]]
7600 + name = "quasi-webview"
7601 + version = "0.79.0"
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.78" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.78" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.79" }
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"] }
@@ -24,9 +24,9 @@
24 24 //! action does**, which is the whole of `524a63fe`'s argument, and the round trip
25 25 //! through an enum is what a description makes unnecessary rather than shorter.
26 26 //!
27 - //! # THE FINDING: a hierarchy of rows has no description
27 + //! # The tag sidebar is an outline
28 28 //!
29 - //! audiofiles' tags are dotted — `drums.kick`, `genre.house` — and the shipped
29 + //! audiofiles' tags are dotted -- `drums.kick`, `genre.house` -- and the shipped
30 30 //! sidebar builds a real tree out of them: `TagNode` with `children`, a
31 31 //! recursive `draw_tag_node`, a disclosure chevron that is deliberately a
32 32 //! separate hit target from the label, per-node expansion persisted by egui, and
@@ -34,25 +34,24 @@
34 34 //! (filtering by the latter "would match zero samples", so its label is not
35 35 //! interactive at all).
36 36 //!
37 - //! None of that is sayable. `RowPart` is `Primary`, `Secondary`, `Meta`,
38 - //! `Actions`, `Tokens`, `Proportion` — there is no depth on a row and no member
39 - //! that holds rows inside a row. `Node::Region` nests, but a region is a rect
40 - //! with its own scroll, not a row with children, and building a tag tree out of
41 - //! nested regions would be describing a drawing rather than a hierarchy.
37 + //! None of that was sayable until `ccaa7e4b`, and this port flattened it: every
38 + //! tag was one row at its full dotted path, which on a vault with two hundred
39 + //! tags is a wall where the shipped one is an outline. `Row::depth` and
40 + //! `Row::open` are what that finding bought, and `tags` says the hierarchy now:
41 + //! a flat list of rows each carrying its indent level, a disclosure on every
42 + //! segment that holds another, and the parent/leaf distinction said in the
43 + //! vocabulary -- a segment that only groups carries no tick, which is the
44 + //! shipped inert label rather than a control that filters to nothing.
42 45 //!
43 - //! So this port **flattens it**: every tag is one row at its full dotted path,
44 - //! togglable as a filter. That is honest about what the filter actually operates
45 - //! on — `required_tags` holds exact paths, and the tree is a navigation
46 - //! convenience over a flat set — and it loses three real things: the grouping, the
47 - //! ability to collapse a branch you are not using, and the parent/leaf
48 - //! distinction. On a vault with two hundred tags the described sidebar is a wall
49 - //! where the shipped one is an outline.
46 + //! Two things are deliberately still the renderer's. Which branches are open
47 + //! after the first frame is the reader's, exactly as egui already held it, so
48 + //! nothing here persists it and no route hears about a fold. And the filter
49 + //! itself is a tick rather than a chip: `required_tags` holds exact paths, so
50 + //! ticking a tag is the write and `Row::toggling` is what says so.
50 51 //!
51 - //! Filed rather than faked -- `ccaa7e4b`, and it was not: that sentence stood
52 - //! here for four days over a task nobody had created. Filed for real 2026-08-21,
53 - //! with the flattening above as its measured cost. Note this is not the same gap as
54 - //! `Node::Heading { level }`, which says how far down the *document* a title
55 - //! sits: that is depth in prose, and this is containment in a set.
52 + //! Not the same distinction as `Node::Heading { level }`, which says how far
53 + //! down the *document* a title sits: that is depth in prose, and this is
54 + //! containment in a set.
56 55 //!
57 56 //! # A fourth consumer for the disabled-control precondition
58 57 //!
@@ -76,7 +75,7 @@
76 75 //! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
77 76 //! A described first run is its own subject.
78 77
79 - use quasi_router::layout::{Token, Tone};
78 + use quasi_router::layout::Tone;
80 79 use quasi_router::{
81 80 Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
82 81 };
@@ -299,20 +298,30 @@
299 298 return side.with(Node::empty("No tags yet."));
300 299 }
301 300
302 - for filter in &all {
303 - side = side.with(Node::Token(Tag {
304 - kind: Token::Chip { removable: false },
305 - label: filter.path.clone(),
306 - tone: if filter.on { Tone::Info } else { Tone::Neutral },
307 - latched: filter.on,
308 - action: Some(Action::post(format!("/tags/{}/filter", filter.path))),
309 - }));
310 - }
301 + // The outline. `ccaa7e4b`: a hierarchy of rows is a flat list of rows each
302 + // carrying its depth, so the dotted paths become an indented list with a
303 + // disclosure on every tag that holds others.
304 + //
305 + // Ticking a tag is the write, which is what `Row::toggling` says: the tick
306 + // is the only affordance for filtering by it, and a button beside a
307 + // checkbox that ignored clicks is what the member exists to stop. A tag
308 + // that only groups is not ticked and not tickable -- filtering by
309 + // `drums` when only `drums.kick` exists matches zero samples, and the
310 + // shipped sidebar refuses it by drawing its label inert. `Row::selected`
311 + // being `None` is that refusal, said in the vocabulary rather than by
312 + // leaving a control out.
313 + side = side.with(Node::List {
314 + rows: outline(&all),
315 + more: None,
316 + });
311 317
312 - // Removing a tag from every sample is not a filter, so it is not a chip. It
318 + // Removing a tag from every sample is not a filter, so it is not a tick. It
313 319 // is a list of the same tags with a destructive act on each, which is the
314 320 // shipped right-click menu made visible -- and the second
315 321 // `ConfirmAction` variant this port replaces.
322 + //
323 + // Flat, and it stays flat: what is removed is one exact path, so an outline
324 + // here would group rows under a parent that the act does not act on.
316 325 side.with(Node::section("Remove a tag everywhere"))
317 326 .with(Node::List {
318 327 rows: all
@@ -334,3 +343,57 @@
334 343 more: None,
335 344 })
336 345 }
346 +
347 + /// The tags as an outline: one row per segment, in path order.
348 + ///
349 + /// A parent appears whether or not it is a tag in its own right, because
350 + /// `drums.kick` with no `drums` beside it is still a `drums` the reader can
351 + /// fold. What separates the two is what the row offers: a real tag is ticked to
352 + /// filter by it, and a parent that only groups offers the chevron and nothing
353 + /// else.
354 + ///
355 + /// Every branch starts open, which is the shipped sidebar's own first frame.
356 + /// Where it goes from there is the renderer's, and the reader's -- see
357 + /// `Row::open`.
358 + fn outline(all: &[super::Filter]) -> Vec<Row> {
359 + // Sorted, so a segment's children follow it. The library answers in its own
360 + // order and the outline is only an outline if `drums.kick` comes after
361 + // `drums`.
362 + let mut paths: Vec<&str> = all.iter().map(|filter| filter.path.as_str()).collect();
363 + paths.sort_unstable();
364 +
365 + let mut rows: Vec<Row> = Vec::with_capacity(paths.len());
366 + let mut said: Vec<String> = Vec::new();
367 + for path in paths {
368 + for (depth, _) in path
369 + .match_indices('.')
370 + .chain(std::iter::once((path.len(), "")))
371 + {
372 + let branch = &path[..depth];
373 + if said.iter().any(|seen| seen == branch) {
374 + continue;
375 + }
376 + said.push(branch.to_owned());
377 + let level = branch.matches('.').count();
378 + let label = branch.rsplit('.').next().unwrap_or(branch);
379 + let mut row = Row::new(label).depth(level);
380 + // A tag in its own right is ticked to filter by it. A segment that
381 + // only groups is neither ticked nor tickable.
382 + if let Some(filter) = all.iter().find(|filter| filter.path == branch) {
383 + row = row.toggling(
384 + filter.on,
385 + Action::post(format!("/tags/{}/filter", filter.path)),
386 + );
387 + }
388 + // A disclosure on anything that holds something, and nothing on a
389 + // leaf: a chevron over an empty branch is an affordance that opens
390 + // onto nothing.
391 + let prefix = format!("{branch}.");
392 + if all.iter().any(|filter| filter.path.starts_with(&prefix)) {
393 + row = row.disclosing(true);
394 + }
395 + rows.push(row);
396 + }
397 + }
398 + rows
399 + }
@@ -5054,43 +5054,68 @@
5054 5054 assert_eq!(library.asked(), ["delete vault 2"]);
5055 5055 }
5056 5056
5057 + /// The rows of the sidebar's first list, which is the tag outline.
5058 + fn tag_rows(screen: &quasi_router::Screen) -> Vec<quasi_router::Row> {
5059 + // The first list after the Tags heading. The sidebar draws vaults and
5060 + // collections above it and the remove-everywhere list below, and all four
5061 + // are lists.
5062 + let mut under_tags = false;
5063 + screen
5064 + .slots
5065 + .iter()
5066 + .filter(|slot| slot.id == "library-side")
5067 + .flat_map(|slot| &slot.body)
5068 + .find_map(|placed| match &placed.node {
5069 + Node::Heading { text, .. } => {
5070 + under_tags = text == "Tags";
5071 + None
5072 + }
5073 + Node::List { rows, .. } if under_tags => Some(rows.clone()),
5074 + _ => None,
5075 + })
5076 + .expect("the tag outline")
5077 + }
5078 +
5057 5079 #[test]
5058 - fn a_tag_filter_is_a_chip_that_latches() {
5059 - // A filter is on or off, which is exactly what Token::Chip's `latched`
5060 - // says, and what a plain badge could not.
5080 + fn a_tag_filter_is_a_tick_because_ticking_it_is_the_write() {
5081 + // `Row::toggling`. The tick is the only affordance for filtering by a tag,
5082 + // and the shipped sidebar's own control is the label itself -- a button
5083 + // beside an inert checkbox is what that member exists to stop.
5061 5084 let library = FakeLibrary::stocked();
5062 - // Two latched chips on the window now, and only the second is this test's:
5063 - // the toolbar's Sidebar toggle latches because the sidebar is showing, which
5064 - // is what a latched chip is for. Named rather than filtered out, so a chip
5065 - // that stops latching fails here rather than silently.
5066 - assert_eq!(latched(&browsed(&library)), ["Sidebar", "drums.kick"]);
5085 + let rows = tag_rows(&browsed(&library));
5086 + let kick = rows
5087 + .iter()
5088 + .find(|row| row.primary() == "kick")
5089 + .expect("the leaf tag");
5090 + assert_eq!(kick.selected, Some(true), "the fixture filters by it");
5091 + assert_eq!(
5092 + kick.toggle.as_ref().and_then(quasi_router::Action::route),
5093 + Some("/tags/drums.kick/filter")
5094 + );
5067 5095
5068 5096 browsing(&library, Request::post("/tags/drums/filter")).unwrap();
5069 5097 assert_eq!(library.asked(), ["toggle drums"]);
5070 5098 }
5071 5099
5072 5100 #[test]
5073 - fn a_tag_is_named_by_its_whole_path_because_the_tree_is_not_described() {
5074 - // THE FINDING. `RowPart` has no depth and no member holds rows inside a
5075 - // row, so the shipped sidebar's TagNode tree flattens to full dotted paths.
5076 - // Honest about what the filter operates on -- `required_tags` holds exact
5077 - // paths -- and it loses the grouping, the collapse, and the
5078 - // parent-that-is-only-a-parent distinction.
5101 + fn the_tags_are_an_outline_rather_than_a_wall_of_dotted_paths() {
5102 + // `ccaa7e4b`. Each row says how deep it sits and each parent carries a
5103 + // disclosure, so `drums.kick` is drawn under `drums` and named by its own
5104 + // last segment rather than by the whole path.
5079 5105 let library = FakeLibrary::stocked();
5080 - let screen = browsed(&library);
5106 + let rows = tag_rows(&browsed(&library));
5081 5107
5082 - // The sidebar's own region, since the toolbar's panel toggles are chips too.
5083 - let chips: Vec<String> = screen
5084 - .slots
5108 + let shape: Vec<(String, usize, Option<bool>)> = rows
5085 5109 .iter()
5086 - .filter(|slot| slot.id == "library-side")
5087 - .flat_map(|slot| &slot.body)
5088 - .filter_map(|placed| match &placed.node {
5089 - Node::Token(tag) => Some(tag.label.clone()),
5090 - _ => None,
5091 - })
5110 + .map(|row| (row.primary(), row.depth, row.open))
5092 5111 .collect();
5093 - assert_eq!(chips, ["drums", "drums.kick"]);
5112 + assert_eq!(
5113 + shape,
5114 + [
5115 + ("drums".to_string(), 0, Some(true)),
5116 + ("kick".to_string(), 1, None),
5117 + ]
5118 + );
5094 5119 }
5095 5120
5096 5121 #[test]