Skip to main content

max / audiofiles

Describe the filter panel Seven shapes: the screen, the axes and the question one axis is, the keys, the tags, the footer and the save box. Every shape already took the app rather than a container, so nothing had to be restructured first; what moved is the reading, into one Panel struct with the numbers already written at each axis's own precision. KEY_GROUPS is a pair of named fields now rather than a tuple, on the rule three waves have already applied to a const the description reads: a description names what it draws and `.1` is not a name. The interval's upper name is quasi-router 0.101.14's new setting. The remove act stays in the row's menu rather than becoming an inline control, which is what `offers` says.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:09 UTC
Signed with PGP, not checked
Commit: 6970b5936dd53a29d4e37872226b9141ab19ab44
Parent: 14047c3
2 files changed, +275 insertions, -131 deletions
M Cargo.lock +5 -5
@@ -4277,7 +4277,7 @@
4277 4277
4278 4278 [[package]]
4279 4279 name = "quasi-router"
4280 - version = "0.101.13"
4280 + version = "0.101.14"
4281 4281 dependencies = [
4282 4282 "makeover-layout",
4283 4283 ]
@@ -7578,10 +7578,6 @@
7578 7578 name = "painhours"
7579 7579 version = "0.1.0"
7580 7580
7581 - [[patch.unused]]
7582 - name = "quasi-type"
7583 - version = "0.1.3"
7584 -
7585 7581 [[patch.unused]]
7586 7582 name = "quasi-axum"
7587 7583 version = "0.101.1"
@@ -7609,3 +7605,7 @@
7609 7605 [[patch.unused]]
7610 7606 name = "quasi-webview"
7611 7607 version = "0.101.1"
7608 +
7609 + [[patch.unused]]
7610 + name = "quasi-type"
7611 + version = "0.1.3"
@@ -63,11 +63,9 @@
63 63 //! filed shape and this is not quite it -- what is here is several forms rather
64 64 //! than one form with two submits -- so it is recorded rather than counted.
65 65
66 - use quasi_router::layout::{FieldKind, Tone, Width};
67 - use quasi_router::{
68 - Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot,
69 - Tag,
70 - };
66 + use quasi_declare::declare;
67 + use quasi_router::layout::Tone;
68 + use quasi_router::{Act, Action, Request, Response, RouteError, Router};
71 69
72 70 use super::{Narrowing, Panels};
73 71
@@ -89,23 +87,34 @@
89 87 /// every section below them and made hunting for one a linear scan. A group is
90 88 /// a [`Node::section`] with a row of tags under it, which is the same shape one
91 89 /// level up from `ui.horizontal_wrapped`.
92 - const KEY_GROUPS: [(&str, [&str; 12]); 2] = [
93 - (
94 - "Major",
95 - [
90 + const KEY_GROUPS: [KeyGroup; 2] = [
91 + KeyGroup {
92 + name: "Major",
93 + keys: [
96 94 "C major", "C# major", "D major", "D# major", "E major", "F major", "F# major",
97 95 "G major", "G# major", "A major", "A# major", "B major",
98 96 ],
99 - ),
100 - (
101 - "Minor",
102 - [
97 + },
98 + KeyGroup {
99 + name: "Minor",
100 + keys: [
103 101 "C minor", "C# minor", "D minor", "D# minor", "E minor", "F minor", "F# minor",
104 102 "G minor", "G# minor", "A minor", "A# minor", "B minor",
105 103 ],
106 - ),
104 + },
107 105 ];
108 106
107 + /// One heading and the keys under it.
108 + ///
109 + /// Named fields rather than a pair, because a description names what it draws
110 + /// and `.1` is not a name. Third const in the transition to be reshaped for it.
111 + struct KeyGroup {
112 + /// The heading.
113 + name: &'static str,
114 + /// The twelve keys under it, spelled the way the library spells them.
115 + keys: [&'static str; 12],
116 + }
117 +
109 118 // The six axes, and the geometry each one is fixed by.
110 119 //
111 120 // They live here rather than in a renderer: the description reads them, and
@@ -234,7 +243,7 @@
234 243
235 244 /// `GET /filters`
236 245 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
237 - Ok(screen(state).into())
246 + Ok(showing(state))
238 247 }
239 248
240 249 /// `POST /filters/axis/{key}`
@@ -263,31 +272,31 @@
263 272 lower.filter(|low| *low > axis.axis.lo),
264 273 upper.filter(|high| *high < axis.axis.hi),
265 274 );
266 - Ok(screen(state).into())
275 + Ok(showing(state))
267 276 }
268 277
269 278 /// `POST /filters/axis/{key}/clear`
270 279 fn widen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
271 280 let axis = axis_named(state, request.captures.require("key")?)?;
272 281 state.filters.narrow(axis.key, None, None);
273 - Ok(screen(state).into())
282 + Ok(showing(state))
274 283 }
275 284
276 285 /// `POST /filters/keys/mode`
277 286 fn key_mode(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
278 287 let compatible = request.payload.get("mode") == Some("compatible");
279 288 state.filters.set_key_mode(compatible);
280 - Ok(screen(state).into())
289 + Ok(showing(state))
281 290 }
282 291
283 292 /// `POST /filters/keys/{key}/toggle`
284 293 fn toggle_key(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
285 294 let key = request.captures.require("key")?;
286 - if !KEY_GROUPS.iter().any(|(_, keys)| keys.contains(&key)) {
295 + if !KEY_GROUPS.iter().any(|group| group.keys.contains(&key)) {
287 296 return Err(RouteError::not_found("no such key"));
288 297 }
289 298 state.filters.toggle_key(key);
290 - Ok(screen(state).into())
299 + Ok(showing(state))
291 300 }
292 301
293 302 /// `POST /filters/keys/clear`
@@ -296,7 +305,7 @@
296 305 return Err(RouteError::not_found("no key is wanted"));
297 306 }
298 307 state.filters.clear_keys();
299 - Ok(screen(state).into())
308 + Ok(showing(state))
300 309 }
301 310
302 311 /// `POST /filters/tags/typing`
@@ -304,7 +313,7 @@
304 313 state
305 314 .filters
306 315 .typed(request.payload.get("tag").unwrap_or_default());
307 - Ok(screen(state).into())
316 + Ok(showing(state))
308 317 }
309 318
310 319 /// `POST /filters/tags/add`
@@ -328,7 +337,7 @@
328 337 // The box empties on the way through, which is the shipped path's own
329 338 // behaviour and is the half a bare `require` would lose.
330 339 state.filters.typed("");
331 - Ok(screen(state).into())
340 + Ok(showing(state))
332 341 }
333 342
334 343 /// `POST /filters/tags/{tag}/remove`
@@ -338,7 +347,7 @@
338 347 return Err(RouteError::not_found("not required"));
339 348 }
340 349 state.filters.unrequire(tag);
341 - Ok(screen(state).into())
350 + Ok(showing(state))
342 351 }
343 352
344 353 /// `POST /filters/tags/clear`
@@ -347,7 +356,7 @@
347 356 return Err(RouteError::not_found("no tag is required"));
348 357 }
349 358 state.filters.clear_tags();
350 - Ok(screen(state).into())
359 + Ok(showing(state))
351 360 }
352 361
353 362 /// `POST /filters/clear`
@@ -356,7 +365,7 @@
356 365 return Err(RouteError::not_found("nothing is filtering"));
357 366 }
358 367 state.filters.clear_all();
359 - Ok(screen(state).into())
368 + Ok(showing(state))
360 369 }
361 370
362 371 /// `POST /filters/save`
@@ -374,208 +383,330 @@
374 383 typed.to_owned()
375 384 };
376 385 state.filters.save_collection(&name);
377 - Ok(screen(state).into())
386 + Ok(showing(state))
378 387 }
379 388
380 - /// The screen: the axes, the keys, the tags, and what matched.
381 - fn screen(state: &Panels<'_>) -> Screen {
382 - let mut pane = Screen::sidebar_content("Filters")
383 - .with(axes(state))
384 - .with(keys(state))
385 - .with(tags(state))
386 - .with(foot(state));
387 - if state.filters.active() {
388 - pane = pane.with(saving(state));
389 - }
390 - pane
389 + /// The panel, read and then described.
390 + pub(super) fn showing(state: &Panels<'_>) -> Response {
391 + Response::from(screen(&read(state)))
391 392 }
392 393
393 - /// The six numeric axes, one interval each.
394 - fn axes(state: &Panels<'_>) -> Slot {
395 - let mut band = Slot::new(AXES, RegionKind::Band).with(Node::section("Filters"));
396 - for narrowing in state.filters.axes() {
397 - band = band.with(axis_form(&narrowing));
398 - }
399 - band
394 + /// What the panel draws, read off the app.
395 + struct Panel {
396 + /// The six numeric axes, in the order the panel offers them.
397 + axes: Vec<Axis>,
398 + /// The two matching rules, and which one is in force.
399 + modes: [Mode; 2],
400 + /// The key groups, with each key's pill and whether it is held down.
401 + groups: [Group; 2],
402 + /// Whether any key is wanted, which is what offers the clear.
403 + keyed: bool,
404 + /// The tags every result has to carry.
405 + tags: Vec<String>,
406 + /// Whether no tag is required.
407 + untagged: bool,
408 + /// What is in the tag box.
409 + typing: String,
410 + /// What matched, pluralised for one.
411 + matched: String,
412 + /// Whether anything is filtering at all.
413 + active: bool,
414 + /// The name the save box would take if nothing were typed into it.
415 + describes: String,
400 416 }
401 417
402 - /// One axis, as the question it is.
418 + /// One numeric axis, as the question it is.
403 419 ///
404 - /// A form rather than a bare field because the two ends submit together: the
405 - /// route needs both, and a `changes` per end would have it guessing at the one
406 - /// that did not move.
407 - fn axis_form(narrowing: &Narrowing) -> Node {
408 - let axis = narrowing.axis;
409 - let mut field = Field {
410 - min: Some(number(axis.lo, axis.decimals)),
411 - max: Some(number(axis.hi, axis.decimals)),
412 - step: Some(number(axis.speed, axis.decimals)),
413 - // The suffix carried its leading space because it was going straight
414 - // into a DragValue. The description writes the symbol alone and the
415 - // spacing is the renderer's.
416 - unit: unit(axis.suffix),
417 - hint: axis.hint.map(str::to_owned),
418 - width: Width::Fill,
419 - ..Field::interval(
420 - lower_name(narrowing.key),
421 - upper_name(narrowing.key),
422 - axis.title,
423 - )
424 - };
425 - if let Some(low) = narrowing.lower {
426 - field = field.value(number(low, axis.decimals));
427 - }
428 - if let Some(high) = narrowing.upper {
429 - field = field.upper_value(number(high, axis.decimals));
430 - }
431 -
432 - let mut region =
433 - Slot::new(format!("filters-axis-{}", narrowing.key), RegionKind::Group).with(Node::Form {
434 - action: Action::post(format!("/filters/axis/{}", narrowing.key)),
435 - submit: "Apply".into(),
436 - fields: vec![field],
437 - });
438 - // Offered only when the axis is narrowed, which is the shipped section's own
439 - // gate: a clear link on an axis nobody has touched is a control that does
440 - // nothing and says the axis is doing something.
441 - if narrowing.lower.is_some() || narrowing.upper.is_some() {
442 - region = region.with(Node::Act(Act::new(
443 - "Clear",
444 - Action::post(format!("/filters/axis/{}/clear", narrowing.key)),
445 - )));
446 - }
447 - Node::Region(region)
420 + /// Every number is a string here because the description carries bounds as
421 + /// text: `RangeAxis::decimals` is what the shipped DragValue was given, and
422 + /// [`number`] is the one place the two meet.
423 + struct Axis {
424 + /// The key the axis is addressed by.
425 + key: &'static str,
426 + /// What the question is called.
427 + title: &'static str,
428 + /// The name the lower end submits under.
429 + lower_name: String,
430 + /// The name the upper end submits under.
431 + upper_name: String,
432 + /// The sentinel edges, which are the rule each end is checked against.
433 + min: String,
434 + max: String,
435 + /// The granularity the value moves in.
436 + step: String,
437 + /// The unit symbol, where the axis has one.
438 + unit: Option<String>,
439 + /// What the axis means, where that needs saying.
440 + hint: Option<&'static str>,
441 + /// The lower end wanted, if one is.
442 + lower: Option<String>,
443 + /// The upper end wanted, if one is.
444 + upper: Option<String>,
445 + /// Whether either end is set.
446 + ///
447 + /// The shipped section's own gate on the clear: a clear link on an axis
448 + /// nobody has touched is a control that does nothing and says the axis is
449 + /// doing something.
450 + narrowed: bool,
448 451 }
449 452
450 - /// The keys wanted, and how they are matched.
451 - fn keys(state: &Panels<'_>) -> Slot {
453 + /// One matching rule, as the act that takes it.
454 + ///
455 + /// Two acts rather than a two-option field, because picking a matching rule is
456 + /// not a value being submitted with anything: it takes effect as it is pressed,
457 + /// which is what the shipped segmented control does.
458 + struct Mode {
459 + /// What the button reads.
460 + label: &'static str,
461 + /// What it writes.
462 + value: &'static str,
463 + /// Why it is there.
464 + why: &'static str,
465 + /// Whether it is the rule in force, which is what greys it out.
466 + held: bool,
467 + }
468 +
469 + /// One key group, drawn.
470 + struct Group {
471 + /// The heading.
472 + name: &'static str,
473 + /// The twelve keys under it.
474 + keys: Vec<Key>,
475 + }
476 +
477 + /// One key, as the pill it is.
478 + struct Key {
479 + /// What the pill reads: the note alone, which is what a producer scans for.
480 + note: &'static str,
481 + /// The whole key, which is what the query compares and the address carries.
482 + spelling: &'static str,
483 + /// Whether it is held down.
484 + wanted: bool,
485 + }
486 +
487 + /// What the panel draws, read off the app.
488 + fn read(state: &Panels<'_>) -> Panel {
452 489 let held = state.filters.keys();
453 - let mut band = Slot::new(KEYS, RegionKind::Band).with(Node::section("Key"));
454 -
455 - // Two acts rather than a two-option field, because picking a matching rule
456 - // is not a value being submitted with anything: it takes effect as it is
457 - // pressed, which is what the shipped segmented control does.
458 - for (compatible, label, why) in [
459 - (false, "Exact", "Match only the keys chosen"),
460 - (
461 - true,
462 - "Compatible",
463 - "Include musically compatible keys, around the circle of fifths",
490 + let tags = state.filters.tags();
491 + let matched = state.filters.matched();
492 + Panel {
493 + axes: state
494 + .filters
495 + .axes()
496 + .into_iter()
497 + .map(|narrowing| axis(&narrowing))
498 + .collect(),
499 + modes: [
500 + Mode {
501 + label: "Exact",
502 + value: "exact",
503 + why: "Match only the keys chosen",
504 + held: !held.compatible,
505 + },
506 + Mode {
507 + label: "Compatible",
508 + value: "compatible",
509 + why: "Include musically compatible keys, around the circle of fifths",
510 + held: held.compatible,
511 + },
512 + ],
513 + groups: KEY_GROUPS.map(|group| Group {
514 + name: group.name,
515 + keys: group
516 + .keys
517 + .iter()
518 + .map(|spelling| Key {
519 + note: spelling.split(' ').next().unwrap_or(spelling),
520 + spelling,
521 + wanted: held.wanted.iter().any(|held| held == spelling),
522 + })
523 + .collect(),
524 + }),
525 + keyed: !held.wanted.is_empty(),
526 + untagged: tags.is_empty(),
527 + tags,
528 + typing: state.filters.typing(),
529 + matched: format!(
530 + "{matched} sample{} match",
531 + if matched == 1 { "" } else { "s" }
464 532 ),
465 - ] {
466 - let mut act = Act::new(
467 - label,
468 - Action::post("/filters/keys/mode")
469 - .with("mode", if compatible { "compatible" } else { "exact" }),
470 - );
471 - if held.compatible == compatible {
472 - act = act.disabled();
473 - }
474 - band = band.with(Node::Act(act)).with(Node::text(why));
533 + active: state.filters.active(),
534 + describes: state.filters.describes(),
475 535 }
536 + }
476 537
477 - for (group, spellings) in KEY_GROUPS {
478 - band = band.with(Node::text(group));
479 - for spelling in spellings {
480 - // The pill reads as the note and stores the whole key, which is the
481 - // shipped row's own split: "C#" is what a producer scans for and
482 - // "C# minor" is what the query compares.
483 - let note = spelling.split(' ').next().unwrap_or(spelling);
484 - let wanted = held.wanted.iter().any(|held| held == spelling);
485 - band = band.with(Node::Token(
486 - Tag::chip(
487 - note,
488 - Action::post(format!("/filters/keys/{spelling}/toggle")),
489 - )
538 + /// One axis, in the words the question uses.
539 + fn axis(narrowing: &Narrowing) -> Axis {
540 + let axis = narrowing.axis;
541 + Axis {
542 + key: narrowing.key,
543 + title: axis.title,
544 + lower_name: lower_name(narrowing.key),
545 + upper_name: upper_name(narrowing.key),
546 + min: number(axis.lo, axis.decimals),
547 + max: number(axis.hi, axis.decimals),
548 + step: number(axis.speed, axis.decimals),
549 + unit: unit(axis.suffix),
550 + hint: axis.hint,
551 + lower: narrowing.lower.map(|low| number(low, axis.decimals)),
552 + upper: narrowing.upper.map(|high| number(high, axis.decimals)),
553 + narrowed: narrowing.lower.is_some() || narrowing.upper.is_some(),
554 + }
555 + }
556 +
557 + declare! {
558 + /// The screen: the axes, the keys, the tags, and what matched.
559 + shape screen(panel: &Panel) -> Screen;
560 +
561 + screen sidebar_content "Filters" {
562 + include axes(panel);
563 + include keys(panel);
564 + include tags(panel);
565 + include foot(panel);
566 + include saving(panel) when panel.active;
567 + }
568 + }
569 +
570 + declare! {
571 + /// The six numeric axes, one interval each.
572 + shape axes(panel: &Panel) -> Slot;
573 +
574 + region AXES as Band {
575 + section "Filters";
576 + for axis in panel.axes.iter() {
577 + include axis_form(axis);
578 + }
579 + }
580 + }
581 +
582 + declare! {
583 + /// One axis, as the question it is.
584 + ///
585 + /// A form rather than a bare field because the two ends submit together:
586 + /// the route needs both, and a `changes` per end would have it guessing at
587 + /// the one that did not move.
588 + ///
589 + /// `upper_name` is a setting rather than the second argument to
590 + /// `Field::interval`, and it landed in quasi-router 0.101.14 for this
591 + /// screen: the `field` production says a kind, a name and a label, and
592 + /// everything else about a field is said in its body.
593 + shape axis_form(axis: &Axis) -> Node;
594 +
595 + region "filters-axis-{axis.key}" as Group {
596 + form post "/filters/axis/{axis.key}" {
597 + submit "Apply";
598 + field Interval &axis.lower_name axis.title {
599 + upper_name &axis.upper_name;
600 + within &axis.min &axis.max;
601 + step &axis.step;
602 + width Fill;
603 + for symbol in axis.unit.iter() {
604 + unit symbol;
605 + }
606 + for &hint in axis.hint.iter() {
607 + hint hint;
608 + }
609 + for low in axis.lower.iter() {
610 + value low;
611 + }
612 + for high in axis.upper.iter() {
613 + upper_value high;
614 + }
615 + }
616 + }
617 + act "Clear" to post "/filters/axis/{axis.key}/clear" when axis.narrowed;
618 + }
619 + }
620 +
621 + declare! {
622 + /// The keys wanted, and how they are matched.
623 + shape keys(panel: &Panel) -> Slot;
624 +
625 + region KEYS as Band {
626 + section "Key";
627 +
628 + for mode in panel.modes.iter() {
629 + act mode.label to post "/filters/keys/mode" with "mode" mode.value {
630 + disabled when mode.held;
631 + }
632 + text mode.why;
633 + }
Lines truncated