Skip to main content

max / audiofiles

Say the rule editor's conditions and actions as repeating groups quasicoherent f7abbc08 closed the finding this module's header records. The regions were always here and nothing said they were slots of one question, so no renderer could number them and "at least one condition" was this file disabling its own last Remove. Both groups say it now. Repeating::least(1) is that rule, stated once where every renderer reads it; the actions take least(0), which is the honest answer -- a rule with no actions is describable and the editor has never stopped anyone writing one. Each slot carries its own remove, because only the slot knows which one it is. The header points at the resolution rather than at the gap.
Author: Max Johnson <me@maxj.phd> · 2026-08-30 20:25 UTC
Signed with PGP, not checked
Commit: e585f9431c8035a6bc4a2b03ea8b9bb60479da5a
Parent: df00536
4 files changed, +162 insertions, -84 deletions
M Cargo.lock +30 -30
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.85.0"
4259 + version = "0.86.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.85.0"
4270 + version = "0.86.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -7556,34 +7556,6 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 - [[patch.unused]]
7560 - name = "quasi-axum"
7561 - version = "0.85.0"
7562 -
7563 - [[patch.unused]]
7564 - name = "quasi-basics"
7565 - version = "0.85.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "quasi-http"
7569 - version = "0.85.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-notifs"
7573 - version = "0.85.0"
7574 -
7575 - [[patch.unused]]
7576 - name = "quasi-store"
7577 - version = "0.1.0"
7578 -
7579 - [[patch.unused]]
7580 - name = "quasi-tauri"
7581 - version = "0.85.0"
7582 -
7583 - [[patch.unused]]
7584 - name = "quasi-webview"
7585 - version = "0.85.0"
7586 -
7587 7559 [[patch.unused]]
7588 7560 name = "kberg"
7589 7561 version = "0.1.0"
@@ -7596,6 +7568,34 @@
7596 7568 name = "painhours"
7597 7569 version = "0.1.0"
7598 7570
7571 + [[patch.unused]]
7572 + name = "quasi-axum"
7573 + version = "0.86.0"
7574 +
7575 + [[patch.unused]]
7576 + name = "quasi-basics"
7577 + version = "0.86.0"
7578 +
7579 + [[patch.unused]]
7580 + name = "quasi-http"
7581 + version = "0.86.0"
7582 +
7583 + [[patch.unused]]
7584 + name = "quasi-notifs"
7585 + version = "0.86.0"
7586 +
7587 + [[patch.unused]]
7588 + name = "quasi-store"
7589 + version = "0.1.0"
7590 +
7591 + [[patch.unused]]
7592 + name = "quasi-tauri"
7593 + version = "0.86.0"
7594 +
7595 + [[patch.unused]]
7596 + name = "quasi-webview"
7597 + version = "0.86.0"
7598 +
7599 7599 [[patch.unused]]
7600 7600 name = "quasi-type"
7601 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.85" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.85" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.86" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.86" }
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"] }
@@ -45,7 +45,7 @@
45 45 //! what *that field* came back with. A described screen that narrowed the
46 46 //! operators itself would be a second copy of `audiofiles_core::rules`.
47 47 //!
48 - //! # THE FINDING: a repeating question repeats one field, and a condition is three
48 + //! # THE FINDING, and what closed it: a repeating question repeated one field
49 49 //!
50 50 //! The task filed against this port named [`Field::repeats`] as the member to
51 51 //! read first, on the reading that a rule's conditions are one question
@@ -54,16 +54,20 @@
54 54 //! together — and [`Repeat`] holds a `Vec<Instance>` where an instance is one
55 55 //! value and one error. An action is two questions the same way.
56 56 //!
57 - //! So the editor does not use it. Each condition is a [`Node::Region`] holding
58 - //! its three fields, with its own remove act, and "Add condition" is an act
59 - //! rather than a renderer's add control. That says everything honestly and
60 - //! loses one thing the member would have given: a renderer has no way to know
61 - //! the regions are slots of one repeating question, so nothing can draw them as
62 - //! a list that grows. Filed on quasicoherent rather than worked around further.
57 + //! So the editor could not use it, and what it lost was stated here: a renderer
58 + //! had no way to know the regions were slots of one repeating question, so
59 + //! nothing could number them or draw them as a list that grows, and "at least
60 + //! one condition" was this file disabling its own last Remove.
61 + //!
62 + //! **Closed by quasicoherent `f7abbc08`** (Max chose c): [`Slot::repeating`]
63 + //! says a region's children are answers to one question, and [`Slot::removes`]
64 + //! says what takes one away. `Repeat` is untouched — it is still a repeating
65 + //! *field*, and this is a repeating *group*. Both groups here say it now, and
66 + //! the floor is [`Repeating::least`] rather than an `unless` in this file.
63 67 //!
64 68 //! Two consumers here, not one: conditions (three fields) and actions (two).
65 - //! Both are in the same editor, which is what makes this a shape rather than a
66 - //! quirk of one screen.
69 + //! Both are in the same editor, which is what made this a shape rather than a
70 + //! quirk of one screen, and what the ruling was measured against.
67 71 //!
68 72 //! # THE SECOND FINDING: a description cannot say "write when the control settles"
69 73 //!
@@ -118,10 +122,13 @@
118 122 //! [`Field::repeats`]: quasi_router::Field::repeats
119 123 //! [`Outcome::File`]: quasi_router::Outcome::File
120 124 //! [`Repeat`]: quasi_router::Repeat
125 + //! [`Repeating::least`]: quasi_router::Repeating::least
126 + //! [`Slot::removes`]: quasi_router::Slot::removes
127 + //! [`Slot::repeating`]: quasi_router::Slot::repeating
121 128
122 129 use quasi_router::layout::{FieldKind, Readiness as Ready, Tone};
123 130 use quasi_router::{
124 - Accepted, Act, Action, Choice, Field, Locating, Node, RegionKind, Request, Response,
131 + Accepted, Act, Action, Choice, Field, Locating, Node, RegionKind, Repeating, Request, Response,
125 132 RouteError, Router, Row, Screen, Slot, Sought, Tag,
126 133 };
127 134
@@ -837,24 +844,39 @@
837 844 )))
838 845 .with(Node::section("When"));
839 846
847 + // The conditions and the actions, each as slots of one repeating question.
848 + // quasicoherent `f7abbc08`: the regions were always here and nothing said
849 + // they were slots, so no renderer could number them and "at least one
850 + // condition" was this file disabling its own last Remove.
851 + //
852 + // `least(1)` is that rule, said once. `least(0)` on the actions is the
853 + // honest answer for them: a rule with no actions is describable and the
854 + // editor has never stopped anyone writing one.
840 855 let testable = state.classifier.testable();
841 - let removable = draft.conditions.len() > 1;
856 + let mut conditions = Slot::new("conditions", RegionKind::Group).repeating(
857 + Repeating::new(
858 + "Condition",
859 + Act::new(
860 + "Add condition",
861 + Action::post("/classifier/draft/conditions/add"),
862 + ),
863 + )
864 + .least(1),
865 + );
842 866 for (at, condition) in draft.conditions.iter().enumerate() {
843 - body = body.with(condition_region(at, condition, &testable, removable));
867 + conditions = conditions.with(condition_region(at, condition, &testable));
844 868 }
845 - body = body.with(Node::Act(Act::new(
846 - "Add condition",
847 - Action::post("/classifier/draft/conditions/add"),
848 - )));
869 + body = body.with(Node::Region(conditions));
849 870
850 871 body = body.with(Node::section("Then"));
872 + let mut actions = Slot::new("actions", RegionKind::Group).repeating(Repeating::new(
873 + "Action",
874 + Act::new("Add action", Action::post("/classifier/draft/actions/add")),
875 + ));
851 876 for (at, action) in draft.actions.iter().enumerate() {
852 - body = body.with(action_region(at, action));
877 + actions = actions.with(action_region(at, action));
853 878 }
854 - body = body.with(Node::Act(Act::new(
855 - "Add action",
856 - Action::post("/classifier/draft/actions/add"),
857 - )));
879 + body = body.with(Node::Region(actions));
858 880
859 881 body = body
860 882 .with(Node::text("Count how many samples match, without writing."))
@@ -878,16 +900,12 @@
878 900 ))
879 901 }
880 902
881 - /// One condition, as three questions grouped.
903 + /// One condition, as three questions grouped: one slot of a repeating question.
882 904 ///
883 - /// See the module header's first finding: this is what a repeating question
884 - /// would have been if a slot could hold more than one field.
885 - fn condition_region(
886 - at: usize,
887 - condition: &super::Testing,
888 - testable: &[super::Testable],
889 - removable: bool,
890 - ) -> Node {
905 + /// See the module header's first finding and what closed it. This region says
906 + /// what takes it away and its parent says how few may be left standing, so the
907 + /// floor is the description's rather than this file's.
908 + fn condition_region(at: usize, condition: &super::Testing, testable: &[super::Testable]) -> Node {
891 909 let chosen = testable.iter().find(|field| field.value == condition.field);
892 910
893 911 let field = Field::select(
@@ -939,19 +957,17 @@
939 957 )));
940 958 }
941 959
942 - // The last condition cannot be removed, which the shipped editor enforced
943 - // by disabling its button. Offered dead rather than hidden, on the rule
944 - // `library`'s last-vault Delete follows.
945 - Node::Region(region.with(Node::Act(unless(
946 - Act::new(
947 - "Remove condition",
948 - Action::post(format!("/classifier/draft/conditions/{at}/remove")),
949 - ),
950 - !removable,
951 - ))))
960 + // The last condition cannot be removed. This file used to say so by
961 + // disabling its own button; `Repeating::least` says it now, and every
962 + // renderer draws it dead rather than hidden -- the rule `library`'s
963 + // last-vault Delete follows, and the one this editor already followed.
964 + Node::Region(region.removes(Act::new(
965 + "Remove condition",
966 + Action::post(format!("/classifier/draft/conditions/{at}/remove")),
967 + )))
952 968 }
953 969
954 - /// One action, as two questions grouped.
970 + /// One action, as two questions grouped: one slot of a repeating question.
955 971 fn action_region(at: usize, action: &super::Doing) -> Node {
956 972 let kind = Field::select(
957 973 "kind",
@@ -980,10 +996,10 @@
980 996 region = region.with(Node::Field(Box::new(tag)));
981 997 }
982 998
983 - Node::Region(region.with(Node::Act(Act::new(
999 + Node::Region(region.removes(Act::new(
984 1000 "Remove action",
985 1001 Action::post(format!("/classifier/draft/actions/{at}/remove")),
986 - ))))
1002 + )))
987 1003 }
988 1004
989 1005 /// Auto-Tagging: the pass, the review door, the head, and the thresholds.
@@ -11613,17 +11613,78 @@
11613 11613 assert!(!has_tag(&editing));
11614 11614 }
11615 11615
11616 + /// The region whose children are slots of one repeating question, by id.
11617 + fn repeating_group<'a>(screen: &'a Screen, id: &str) -> &'a quasi_router::Slot {
11618 + fn find<'a>(slot: &'a quasi_router::Slot, id: &str) -> Option<&'a quasi_router::Slot> {
11619 + if slot.id == id {
11620 + return Some(slot);
11621 + }
11622 + slot.body.iter().find_map(|placed| match &placed.node {
11623 + Node::Region(inner) => find(inner, id),
11624 + _ => None,
11625 + })
11626 + }
11627 + screen
11628 + .slots
11629 + .iter()
11630 + .find_map(|slot| find(slot, id))
11631 + .unwrap_or_else(|| panic!("no region called {id:?}"))
11632 + }
11633 +
11616 11634 #[test]
11617 - fn the_last_condition_cannot_be_removed_and_is_offered_dead() {
11635 + fn the_conditions_and_the_actions_are_slots_of_one_repeating_question() {
11636 + // quasicoherent `f7abbc08`. The regions were always here; what is new is
11637 + // that they say so, which is what lets a renderer number them and enforce
11638 + // the floor. Two of them on one screen, which is what made this a shape
11639 + // rather than a quirk of one editor.
11640 + let editing = FakeClassifier::editing();
11641 + let screen = classified(&editing);
11642 +
11643 + let conditions = repeating_group(&screen, "conditions");
11644 + let repeating = conditions.repeating.as_deref().expect("conditions repeat");
11645 + assert_eq!(repeating.one, "Condition");
11646 + assert_eq!(repeating.add.label, "Add condition");
11647 +
11648 + let actions = repeating_group(&screen, "actions");
11649 + assert_eq!(
11650 + actions.repeating.as_deref().map(|r| r.one.as_str()),
11651 + Some("Action")
11652 + );
11653 +
11654 + // Every slot says what takes it away, on itself: only the slot knows which
11655 + // one it is.
11656 + for group in [conditions, actions] {
11657 + for placed in &group.body {
11658 + let Node::Region(slot) = &placed.node else {
11659 + panic!("a slot is not a region: {:?}", placed.node);
11660 + };
11661 + assert!(
11662 + slot.removes.is_some(),
11663 + "{} says nothing removes it",
11664 + slot.id
11665 + );
11666 + }
11667 + }
11668 + }
11669 +
11670 + #[test]
11671 + fn the_last_condition_cannot_be_removed_and_the_description_says_so() {
11672 + // This file used to say it by disabling its own button, which put the rule
11673 + // in an app where every renderer needs it. `Repeating::least` says it once
11674 + // and all three read `may_remove`; that the boundary is drawn dead rather
11675 + // than hidden is asserted on the renderers' side.
11618 11676 let mut editing = FakeClassifier::editing();
11619 - let removable = |classifier: &FakeClassifier| {
11620 - deep_acts(&classified(classifier))
11621 - .into_iter()
11622 - .find(|act| act.label == "Remove condition")
11623 - .expect("the act is offered either way")
11624 - .interactive()
11677 + let group = |classifier: &FakeClassifier| {
11678 + let screen = classified(classifier);
11679 + let slot = repeating_group(&screen, "conditions");
11680 + let repeating = slot.repeating.as_deref().expect("conditions repeat");
11681 + (repeating.least, slot.body.len())
11625 11682 };
11626 - assert!(!removable(&editing), "the only condition could be removed");
11683 +
11684 + let (least, standing) = group(&editing);
11685 + assert_eq!(least, 1, "the floor is not stated");
11686 + assert_eq!(standing, 1);
11687 + assert!(standing <= least, "the only condition could be removed");
11627 11688
11628 11689 editing
11629 11690 .authoring
@@ -11635,7 +11696,8 @@
11635 11696 op: "gt".to_owned(),
11636 11697 value: "120".to_owned(),
11637 11698 });
11638 - assert!(removable(&editing));
11699 + let (least, standing) = group(&editing);
11700 + assert!(standing > least);
11639 11701 }
11640 11702
11641 11703 #[test]