Skip to main content

max / audiofiles

Describe the sidebar Four shapes: the sidebar and its three sections. All three sections took the region and handed it back, so all three answer with nodes the region splices. `outline` answered `Vec<Row>`, which is a type no shape can declare. What it actually does is flatten dotted paths into an indented list, so it is a read now and the declaration writes the rows from its data. That is the restructure the rule asks for rather than a park: nothing about flattening a hierarchy was ever a description. The vault Delete is what earned `offers` in quasi-declare 0.1.2. It is a menu control that is dead while there is one vault left, and a control written as an expression cannot carry a guard. `filters::tags` moves to the same spelling in the same pass.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:35 UTC
Signed with PGP, not checked
Commit: 786f57d1bd7519b2d3245fdf43bf8d5361857b32
Parent: 0db4022
3 files changed, +287 insertions, -204 deletions
M Cargo.lock +13 -13
@@ -4257,7 +4257,7 @@
4257 4257
4258 4258 [[package]]
4259 4259 name = "quasi-declare"
4260 - version = "0.1.1"
4260 + version = "0.1.2"
4261 4261 dependencies = [
4262 4262 "proc-macro2",
4263 4263 "quote",
@@ -7566,6 +7566,18 @@
7566 7566 "winnow 1.0.4",
7567 7567 ]
7568 7568
7569 + [[patch.unused]]
7570 + name = "kberg"
7571 + version = "0.1.0"
7572 +
7573 + [[patch.unused]]
7574 + name = "ops-status"
7575 + version = "0.1.0"
7576 +
7577 + [[patch.unused]]
7578 + name = "painhours"
7579 + version = "0.1.0"
7580 +
7569 7581 [[patch.unused]]
7570 7582 name = "quasi-axum"
7571 7583 version = "0.101.1"
@@ -7594,18 +7606,6 @@
7594 7606 name = "quasi-webview"
7595 7607 version = "0.101.1"
7596 7608
7597 - [[patch.unused]]
7598 - name = "kberg"
7599 - version = "0.1.0"
7600 -
7601 - [[patch.unused]]
7602 - name = "ops-status"
7603 - version = "0.1.0"
7604 -
7605 - [[patch.unused]]
7606 - name = "painhours"
7607 - version = "0.1.0"
7608 -
7609 7609 [[patch.unused]]
7610 7610 name = "quasi-type"
7611 7611 version = "0.1.3"
@@ -65,8 +65,7 @@
65 65 //! than one form with two submits -- so it is recorded rather than counted.
66 66
67 67 use quasi_declare::declare;
68 - use quasi_router::layout::Tone;
69 - use quasi_router::{Act, Action, Request, Response, RouteError, Router};
68 + use quasi_router::{Action, Request, Response, RouteError, Router};
70 69
71 70 use super::{Narrowing, Panels};
72 71
@@ -667,8 +666,9 @@
667 666 list {
668 667 for tag in panel.tags.iter() {
669 668 row tag {
670 - offers Act::new("Remove", Action::post("/filters/tags/{tag}/remove"))
671 - .tone(Tone::Danger);
669 + offers "Remove" to post "/filters/tags/{tag}/remove" {
670 + tone Danger;
671 + }
672 672 }
673 673 }
674 674 } unless panel.untagged;
@@ -75,10 +75,9 @@
75 75 //! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
76 76 //! A described first run is its own subject.
77 77
78 + use quasi_declare::declare;
78 79 use quasi_router::layout::{Nesting, Tone};
79 - use quasi_router::{
80 - Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
81 - };
80 + use quasi_router::{Action, Request, Response, RouteError, Router, Tag};
82 81
83 82 use super::{Holding, Panels};
84 83
@@ -175,194 +174,172 @@
175 174 /// What the shipped Delete says when there is only one vault left.
176 175 const LAST_VAULT: &str = "Create another vault first, audiofiles needs at least one.";
177 176
178 - /// The sidebar, as a region something else holds.
179 - pub fn body(state: &Panels<'_>) -> Slot {
180 - let side = Slot::new(SIDE, RegionKind::Sidebar);
181 - let side = vaults(side, state);
182 - let side = collections(side, state);
183 - tags(side, state)
177 + /// What the sidebar draws, read off the app.
178 + struct Sidebar {
179 + /// The vaults, in the order the library answers them.
180 + vaults: Vec<Vault>,
181 + /// Whether there is only one, which is what deadens every Delete.
182 + alone: bool,
183 + /// The collections, manual and dynamic.
184 + collections: Vec<Collection>,
185 + /// Whether there are none.
186 + uncollected: bool,
187 + /// The tags as an outline, one row per segment in path order.
188 + outline: Vec<Outlined>,
189 + /// The same tags flat, for the removals.
190 + ///
191 + /// Flat, and it stays flat: what is removed is one exact path, so an
192 + /// outline here would group rows under a parent the act does not act on.
193 + removable: Vec<Removable>,
194 + /// Whether there are none.
195 + untagged: bool,
184 196 }
185 197
186 - /// The vaults, and what can be done to one.
187 - fn vaults(side: Slot, state: &Panels<'_>) -> Slot {
188 - let all = state.library.vaults();
189 - let alone = all.len() < 2;
190 -
191 - // The door to the described New Vault modal (see `naming`). It was an
192 - // `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
193 - // was the one control on this screen whose answer was drawn by hand.
194 - let mut side = side
195 - .with(Node::section("Vaults"))
196 - .with(Node::Act(Act::new("New vault", Action::get("/vaults/new"))));
197 -
198 - let mut rows = Vec::with_capacity(all.len());
199 - for vault in &all {
200 - let mut delete = Act::new(
201 - "Delete",
202 - Action::post(format!("/vaults/{}/delete", vault.id)),
203 - )
204 - .tone(Tone::Danger)
205 - // `ConfirmAction::DeleteVfs`, said where the action is. See the
206 - // module header.
207 - .confirm(format!(
208 - "Delete vault \"{}\" and all its contents?",
209 - vault.name
210 - ));
211 - if alone {
212 - // Offered dead rather than hidden, which is the shipped menu's
213 - // choice: "Always render Delete so the user can see the capability
214 - // exists."
215 - delete = delete.disabled();
216 - }
217 -
218 - // `offers` rather than `act`: the shipped affordance is a right-click
219 - // menu, and `Row::menu` is what "held back until the host asks" means.
220 - // An inline Delete on every vault row would be a different screen.
221 - let mut row = Row::new(&vault.name)
222 - .activate(Action::post(format!("/vaults/{}/open", vault.id)))
223 - .offers(Act::new(
224 - "Rename",
225 - Action::get(format!("/vaults/{}/rename", vault.id)),
226 - ))
227 - .offers(delete);
228 - row.current = vault.current;
229 - rows.push(row);
230 - }
231 - side = side.with(Node::List { rows, more: None });
232 -
233 - if alone {
234 - // The precondition, said beside the control rather than on it. THE
235 - // FINDING, fourth consumer -- see the module header.
236 - side = side.with(Node::Text {
237 - text: LAST_VAULT.to_owned(),
238 - tone: Tone::Info,
239 - });
240 - }
241 - side
198 + /// One vault, and what can be done to it.
199 + struct Vault {
200 + /// The row's own id, which its addresses are built from.
201 + id: i64,
202 + /// What it is called.
203 + name: String,
204 + /// What deleting it says before it runs.
205 + ///
206 + /// `ConfirmAction::DeleteVfs`, said where the action is. See the module
207 + /// header.
208 + confirm: String,
209 + /// Whether it is the one being browsed.
210 + current: bool,
242 211 }
243 212
244 - /// The collections, manual and dynamic.
245 - fn collections(side: Slot, state: &Panels<'_>) -> Slot {
246 - let all = state.library.collections();
247 - let mut side = side.with(Node::section("Collections"));
248 -
249 - if all.is_empty() {
250 - return side.with(Node::empty("No collections yet."));
251 - }
252 -
253 - let mut rows = Vec::with_capacity(all.len());
254 - for collection in &all {
255 - // What kind it is, as a token rather than a suffix on the name. The
256 - // shipped row appends " (auto)" or " (12)" to the label, with a comment
257 - // saying it is a text suffix "instead of a glyph (per the no-emoji brand
258 - // rule, and for accessibility)" -- which is right about the glyph and
259 - // still puts a second fact inside the name.
260 - let mark = match collection.holding {
261 - Holding::Dynamic => Tag::badge("auto"),
262 - Holding::Fixed(count) => Tag::badge(count.to_string()),
263 - };
264 - let mut row = Row::new(&collection.name)
265 - .token(mark)
266 - .activate(if collection.active {
267 - Action::post("/collections/close")
268 - } else {
269 - Action::post(format!("/collections/{}/open", collection.id))
270 - })
271 - .offers(
272 - Act::new(
273 - "Delete",
274 - Action::post(format!("/collections/{}/delete", collection.id)),
275 - )
276 - .tone(Tone::Danger)
277 - .confirm(format!("Delete collection \"{}\"?", collection.name)),
278 - );
279 - row.current = collection.active;
280 - rows.push(row);
281 - }
282 -
283 - side = side.with(Node::List { rows, more: None });
284 - side
213 + /// One collection, and what can be done to it.
214 + struct Collection {
215 + /// What it is called.
216 + name: String,
217 + /// What kind it is, as a token rather than a suffix on the name.
218 + ///
219 + /// The shipped row appends " (auto)" or " (12)" to the label, with a comment
220 + /// saying it is a text suffix "instead of a glyph (per the no-emoji brand
221 + /// rule, and for accessibility)" -- which is right about the glyph and still
222 + /// puts a second fact inside the name.
223 + mark: String,
224 + /// Where pressing the row goes: closing the one showing, opening any other.
225 + opens: String,
226 + /// What deleting it says before it runs.
227 + confirm: String,
228 + /// The row's own id, which the delete address is built from.
229 + id: i64,
230 + /// Whether it is the one being shown.
231 + active: bool,
285 232 }
286 233
287 - /// The tags, flat.
288 - ///
289 - /// See the module header: the hierarchy the shipped sidebar draws has no
290 - /// description, so what is here is every tag at its full path. The chips latch,
291 - /// because a tag filter is on or off and that is exactly what
292 - /// [`Token::Chip`]'s `latched` says.
293 - fn tags(side: Slot, state: &Panels<'_>) -> Slot {
294 - let all = state.library.tags();
295 - let mut side = side.with(Node::section("Tags"));
296 -
297 - if all.is_empty() {
298 - return side.with(Node::empty("No tags yet."));
299 - }
300 -
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 - });
317 -
318 - // Removing a tag from every sample is not a filter, so it is not a tick. It
319 - // is a list of the same tags with a destructive act on each, which is the
320 - // shipped right-click menu made visible -- and the second
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.
325 - side.with(Node::section("Remove a tag everywhere"))
326 - .with(Node::List {
327 - rows: all
328 - .iter()
329 - .map(|filter| {
330 - Row::new(&filter.path).offers(
331 - Act::new(
332 - "Remove",
333 - Action::post(format!("/tags/{}/remove", filter.path)),
334 - )
335 - .tone(Tone::Danger)
336 - .confirm(format!(
337 - "Remove tag \"{}\" from every sample that has it?",
338 - filter.path
339 - )),
340 - )
341 - })
342 - .collect(),
343 - more: None,
344 - })
345 - }
346 -
347 - /// The tags as an outline: one row per segment, in path order.
234 + /// One segment of the tag outline.
348 235 ///
349 236 /// A parent appears whether or not it is a tag in its own right, because
350 237 /// `drums.kick` with no `drums` beside it is still a `drums` the reader can
351 238 /// fold. What separates the two is what the row offers: a real tag is ticked to
352 239 /// filter by it, and a parent that only groups offers the chevron and nothing
353 240 /// else.
241 + struct Outlined {
242 + /// The last segment, which is what the row reads.
243 + label: String,
244 + /// How far in it sits.
245 + depth: Nesting,
246 + /// The tick, for a segment that is a tag in its own right.
247 + ///
248 + /// Absent for a segment that only groups: filtering by `drums` when only
249 + /// `drums.kick` exists matches zero samples, and the shipped sidebar refuses
250 + /// it by drawing its label inert. `Row::selected` staying `None` is that
251 + /// refusal said in the vocabulary rather than by leaving a control out.
252 + tick: Option<Tick>,
253 + /// Whether it holds anything.
254 + ///
255 + /// A disclosure on anything that holds something, and nothing on a leaf: a
256 + /// chevron over an empty branch is an affordance that opens onto nothing.
257 + holds: bool,
258 + }
259 +
260 + /// The filter a real tag carries.
261 + struct Tick {
262 + /// The whole dotted path, which the address carries.
263 + path: String,
264 + /// Whether the filter is in force.
265 + on: bool,
266 + }
267 +
268 + /// One tag, and the way to take it off every sample that has it.
269 + struct Removable {
270 + /// The whole dotted path.
271 + path: String,
272 + /// What removing it says before it runs.
273 + confirm: String,
274 + }
275 +
276 + /// What the sidebar draws, read off the app.
277 + fn read(state: &Panels<'_>) -> Sidebar {
278 + let vaults = state.library.vaults();
279 + let collections = state.library.collections();
280 + let tags = state.library.tags();
281 + Sidebar {
282 + alone: vaults.len() < 2,
283 + vaults: vaults
284 + .iter()
285 + .map(|vault| Vault {
286 + id: vault.id,
287 + name: vault.name.clone(),
288 + confirm: format!("Delete vault \"{}\" and all its contents?", vault.name),
289 + current: vault.current,
290 + })
291 + .collect(),
292 + uncollected: collections.is_empty(),
293 + collections: collections
294 + .iter()
295 + .map(|collection| Collection {
296 + name: collection.name.clone(),
297 + mark: match collection.holding {
298 + Holding::Dynamic => "auto".to_owned(),
299 + Holding::Fixed(count) => count.to_string(),
300 + },
301 + opens: if collection.active {
302 + "/collections/close".to_owned()
303 + } else {
304 + format!("/collections/{}/open", collection.id)
305 + },
306 + confirm: format!("Delete collection \"{}\"?", collection.name),
307 + id: collection.id,
308 + active: collection.active,
309 + })
310 + .collect(),
311 + outline: outline(&tags),
312 + untagged: tags.is_empty(),
313 + removable: tags
314 + .iter()
315 + .map(|filter| Removable {
316 + path: filter.path.clone(),
317 + confirm: format!(
318 + "Remove tag \"{}\" from every sample that has it?",
319 + filter.path
320 + ),
321 + })
322 + .collect(),
323 + }
324 + }
325 +
326 + /// The tags as an outline: one segment per row, in path order.
327 + ///
328 + /// Data rather than rows, which is what took it out of the population: a shape
329 + /// answering `Vec<Row>` can never be declared, and what this actually does is
330 + /// flatten a hierarchy. The declaration writes the rows from it.
354 331 ///
355 332 /// Every branch starts open, which is the shipped sidebar's own first frame.
356 333 /// Where it goes from there is the renderer's, and the reader's -- see
357 334 /// `Row::open`.
358 - fn outline(all: &[super::Filter]) -> Vec<Row> {
335 + fn outline(all: &[super::Filter]) -> Vec<Outlined> {
359 336 // Sorted, so a segment's children follow it. The library answers in its own
360 337 // order and the outline is only an outline if `drums.kick` comes after
361 338 // `drums`.
362 339 let mut paths: Vec<&str> = all.iter().map(|filter| filter.path.as_str()).collect();
363 340 paths.sort_unstable();
364 341
365 - let mut rows: Vec<Row> = Vec::with_capacity(paths.len());
342 + let mut rows: Vec<Outlined> = Vec::with_capacity(paths.len());
366 343 let mut said: Vec<String> = Vec::new();
367 344 for path in paths {
368 345 for (depth, _) in path
@@ -375,26 +352,138 @@
375 352 }
376 353 said.push(branch.to_owned());
377 354 let level = branch.matches('.').count();
378 - let label = branch.rsplit('.').next().unwrap_or(branch);
379 - let mut row =
380 - Row::new(label).depth(Nesting::at(u8::try_from(level).unwrap_or(u8::MAX)));
381 - // A tag in its own right is ticked to filter by it. A segment that
382 - // only groups is neither ticked nor tickable.
383 - if let Some(filter) = all.iter().find(|filter| filter.path == branch) {
384 - row = row.toggling(
385 - filter.on,
386 - Action::post(format!("/tags/{}/filter", filter.path)),
387 - );
388 - }
389 - // A disclosure on anything that holds something, and nothing on a
390 - // leaf: a chevron over an empty branch is an affordance that opens
391 - // onto nothing.
392 355 let prefix = format!("{branch}.");
393 - if all.iter().any(|filter| filter.path.starts_with(&prefix)) {
394 - row = row.disclosing(true);
395 - }
396 - rows.push(row);
356 + rows.push(Outlined {
357 + label: branch.rsplit('.').next().unwrap_or(branch).to_owned(),
358 + depth: Nesting::at(u8::try_from(level).unwrap_or(u8::MAX)),
359 + tick: all
360 + .iter()
361 + .find(|filter| filter.path == branch)
362 + .map(|filter| Tick {
363 + path: filter.path.clone(),
364 + on: filter.on,
365 + }),
366 + holds: all.iter().any(|filter| filter.path.starts_with(&prefix)),
367 + });
397 368 }
398 369 }
399 370 rows
400 371 }
372 +
373 + /// The sidebar, as a region something else holds.
374 + pub fn body(state: &Panels<'_>) -> quasi_router::Slot {
375 + side(&read(state))
376 + }
377 +
378 + declare! {
379 + /// The sidebar: vaults, collections, and the tags you can filter by.
380 + shape side(sidebar: &Sidebar) -> Slot;
381 +
382 + region SIDE as Sidebar {
383 + extend vaults(sidebar);
384 + extend collections(sidebar);
385 + extend tags(sidebar);
386 + }
387 + }
388 +
389 + declare! {
390 + /// The vaults, and what can be done to one.
391 + shape vaults(sidebar: &Sidebar) -> Vec<Node>;
392 +
393 + section "Vaults";
394 +
395 + // The door to the described New Vault modal (see `naming`). It was an
396 + // `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
397 + // was the one control on this screen whose answer was drawn by hand.
398 + act "New vault" to get "/vaults/new";
399 +
400 + list {
401 + for vault in sidebar.vaults.iter() {
402 + row &vault.name {
403 + current vault.current;
404 + activate to post "/vaults/{vault.id}/open";
405 +
406 + // `offers` rather than `act`: the shipped affordance is a
407 + // right-click menu, and `Row::menu` is what "held back until
408 + // the host asks" means. An inline Delete on every vault row
409 + // would be a different screen.
410 + offers "Rename" to get "/vaults/{vault.id}/rename";
411 + offers "Delete" to post "/vaults/{vault.id}/delete" {
412 + tone Danger;
413 + confirm &vault.confirm;
414 + // Offered dead rather than hidden, which is the shipped
415 + // menu's choice: "Always render Delete so the user can see
416 + // the capability exists."
417 + disabled when sidebar.alone;
418 + }
419 + }
420 + }
421 + }
422 +
423 + // The precondition, said beside the control rather than on it. THE FINDING,
424 + // fourth consumer -- see the module header.
425 + toned LAST_VAULT Tone::Info when sidebar.alone;
426 + }
427 +
428 + declare! {
429 + /// The collections, manual and dynamic.
430 + shape collections(sidebar: &Sidebar) -> Vec<Node>;
431 +
432 + section "Collections";
433 + empty "No collections yet." when sidebar.uncollected;
434 + list {
435 + for collection in sidebar.collections.iter() {
436 + row &collection.name {
437 + token Tag::badge(&collection.mark);
438 + current collection.active;
439 + activate to post &collection.opens;
440 + offers "Delete" to post "/collections/{collection.id}/delete" {
441 + tone Danger;
442 + confirm &collection.confirm;
443 + }
444 + }
445 + }
446 + } unless sidebar.uncollected;
447 + }
448 +
449 + declare! {
450 + /// The tags, as an outline and then as a list of things to remove.
451 + ///
452 + /// See the module header. Ticking a tag is the write, which is what
453 + /// `Row::toggling` says: the tick is the only affordance for filtering by
454 + /// it, and a button beside a checkbox that ignored clicks is what the
455 + /// member exists to stop.
456 + ///
457 + /// Removing a tag from every sample is not a filter, so it is not a tick.
458 + /// It is a list of the same tags with a destructive act on each, which is
459 + /// the shipped right-click menu made visible -- and the second
460 + /// `ConfirmAction` variant this port replaces.
461 + shape tags(sidebar: &Sidebar) -> Vec<Node>;
462 +
463 + section "Tags";
464 + empty "No tags yet." when sidebar.untagged;
465 +
466 + list {
467 + for tag in sidebar.outline.iter() {
468 + row &tag.label {
469 + depth tag.depth;
470 + for tick in tag.tick.iter() {
471 + toggling tick.on Action::post("/tags/{tick.path}/filter");
472 + }
473 + disclosing true when tag.holds;
474 + }
475 + }
476 + } unless sidebar.untagged;
477 +
478 + section "Remove a tag everywhere" unless sidebar.untagged;
479 + list {
480 + for tag in sidebar.removable.iter() {
481 + row &tag.path {
482 + offers "Remove" to post "/tags/{tag.path}/remove" {
483 + tone Danger;
Lines truncated