Skip to main content

max / audiofiles

Describe the help overlay, off the one table the keys come from The seventh audiofiles port, and the second untested surface it reaches: Runtime::with_chrome had never been called by this port, so Chrome was written, answered by the renderer, and consumed by nothing. Binding's own header says what this is for -- a help overlay that lists the bindings is otherwise a second, hand-written copy free to drift from what the keys actually do -- and ui::overlays::draw_shortcuts_tab is that copy, 26 rows in seven hand-grouped arrays, naming keys handled several hundred lines away in editor::handle_keyboard with nothing checking that the two agree. Here help::chrome is the only table: the host binds it so the keys work, the screen lists it so the help is what the keys are, and a test asserts the two are equal and that every bound key reaches a route this router serves. THE FINDING is that the table is four rows and not 26. Sixteen of the app's shortcuts are bare letters, and none can be declared: pressed_binding reads raw input before the screen draws with no focus guard, so a described audiofiles with its real shortcuts would eat every letter typed into the tag field, the rename pattern and the search box. The shipped handle_keyboard opens with that guard and a comment saying why. What is declared is the safe remainder -- function keys and command chords. A test asserts it, and names itself as the thing to delete when the guard exists. The two tabs are a second consumer for the overlay-refresh finding, and a sharper one than the rename preview: switching a tab inside an overlay cannot answer a screen (that clears the layer stack) or Over (that stacks a copy), so a tabbed overlay is not buildable without fragments and nothing says so. Two more things the screen could not say, both noted rather than filed: a Binding has no group, so 26 rows would be a wall rather than a reference; and an action cannot sit inside a sentence, so the features tab's three live links are written out in words. Not ported, and nothing replaces it: draw_confirm_dialog. Ten ConfirmAction variants, a pending_confirm field, a dispatcher and a 140-line match producing a title, prompt, detail, button label and danger flag -- all of it is Act::confirm and Act::tone, which quasi has had since 524a63fe. Counted in quasi/mod.rs. 7 tests, 451 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 20:08 UTC
Signed with PGP, not checked
Commit: a7c056d1b0bea68a1d5892c397ce0d57840aabde
Parent: 65abb3c
4 files changed, +436 insertions, -3 deletions
@@ -46,6 +46,24 @@
46 46 //! tag and silently lost Cmd+Z. So the rule is not "reads through a handle,
47 47 //! writes through an intent" but **what the app does about a write decides
48 48 //! where the write goes**. [`Detail`]'s header has the long form.
49 + //!
50 + //! # What is not a capability, because it is not a screen
51 + //!
52 + //! `ui::overlays::draw_confirm_dialog` is a ten-variant `ConfirmAction` enum, a
53 + //! `pending_confirm` field, an `execute_confirmed_action` dispatcher and a
54 + //! 140-line `match` producing a title, a prompt, a detail line, a button label
55 + //! and a danger flag. None of it is ported, and nothing replaces it, because
56 + //! all of it is [`Act::confirm`](quasi_router::Act::confirm) and
57 + //! [`Act::tone`](quasi_router::Act::tone) — which quasi has had since
58 + //! `524a63fe`, and whose header names this exact shape: "Destructiveness is a
59 + //! property of the action, known where the action is described, and until this
60 + //! existed every app expressed it by calling a JS helper at the call site."
61 + //!
62 + //! A described control that destroys something says `confirm` on itself, the
63 + //! runtime answers `Step::Ask`, and the host draws whatever asking looks like
64 + //! for it. [`sync`]'s Disconnect has done that since it landed. **Ten variants
65 + //! replaced by two builder methods**, and the port's contribution is counting
66 + //! them rather than writing anything.
49 67
50 68 // Handlers take their request by value because `quasi_router::Handler` is a
51 69 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
@@ -56,6 +74,7 @@
56 74 pub mod detail;
57 75 pub mod export;
58 76 pub mod files;
77 + pub mod help;
59 78 pub mod panel;
60 79 pub mod settings;
61 80 pub mod sync;
@@ -1632,8 +1651,8 @@
1632 1651 /// cost is nothing, and building it fresh is what lets the state borrow.
1633 1652 #[must_use]
1634 1653 pub fn router<'a>() -> Router<Panels<'a>> {
1635 - bulk::routes(detail::routes(export::routes(files::routes(sync::routes(
1636 - settings::routes(Router::new()),
1654 + help::routes(bulk::routes(detail::routes(export::routes(files::routes(
1655 + sync::routes(settings::routes(Router::new())),
1637 1656 )))))
1638 1657 }
1639 1658
@@ -554,7 +554,13 @@
554 554 Some(runtime) => runtime,
555 555 none => match answer(host, Request::get(home)) {
556 556 Ok(response) => match response.outcome {
557 - quasi_router::Outcome::Screen(screen) => none.insert(Runtime::new(screen)),
557 + // The app's keys, bound to the one table `help::chrome`
558 + // holds and the help overlay lists. Every described
559 + // window gets them, which is what "works from every
560 + // screen" means for an app that has several.
561 + quasi_router::Outcome::Screen(screen) => {
562 + none.insert(Runtime::new(screen).with_chrome(super::help::chrome()))
563 + }
558 564 other => {
559 565 ui.label(format!("the home address answered {other:?}"));
560 566 return;
@@ -2636,3 +2636,157 @@
2636 2636 assert!(said(overlay(&response)).contains("60 chosen"));
2637 2637 assert!(said(overlay(&response)).contains("and 10 more"));
2638 2638 }
2639 +
2640 + // The help overlay.
2641 +
2642 + /// A router call against the help overlay.
2643 + ///
2644 + /// It borrows nothing: the shortcuts are read off `help::chrome`, which is a
2645 + /// free function, and the features tab is a constant. That is the point of the
2646 + /// screen rather than an accident of the fixture -- a help overlay that needed
2647 + /// app state would be describing something other than the app's own keys.
2648 + fn helping(request: Request) -> Result<Response, quasi_router::RouteError> {
2649 + let store = Store::default();
2650 + let sync = Offline;
2651 + let files = FakeFiles::default();
2652 + let themes = themes();
2653 + let state = Panels {
2654 + config: &store,
2655 + sync: &sync,
2656 + files: &files,
2657 + export: &Idle,
2658 + detail: &Unfocused,
2659 + bulk: &Unchosen,
2660 + themes: &themes,
2661 + };
2662 + router().handle(&state, request)
2663 + }
2664 +
2665 + #[test]
2666 + fn the_help_overlay_lists_exactly_the_keys_that_are_bound() {
2667 + // THE POINT OF THIS PORT. `Binding`'s own header says a help overlay is
2668 + // otherwise "a second, hand-written copy of them, free to drift from what
2669 + // the keys actually do", and the shipped tab is that copy in seven arrays.
2670 + // Here the two cannot disagree, and this is what says so.
2671 + let response = helping(Request::get("/help")).unwrap();
2672 + let (_, rows) = table_of(overlay(&response));
2673 +
2674 + let listed: Vec<(String, String)> = rows
2675 + .iter()
2676 + .map(|row| (cell_text(row, 0), cell_text(row, 1)))
2677 + .collect();
2678 + let bound: Vec<(String, String)> = super::help::chrome()
2679 + .bindings
2680 + .iter()
2681 + .map(|binding| (binding.key.clone(), binding.label.clone()))
2682 + .collect();
2683 +
2684 + assert_eq!(listed, bound);
2685 + assert!(!bound.is_empty());
2686 + }
2687 +
2688 + #[test]
2689 + fn every_bound_key_points_at_an_address_this_router_serves() {
2690 + // The other half of "cannot disagree": a binding naming a route that does
2691 + // not exist would be a NotFound the first time it was pressed, which is a
2692 + // lie in a table that only shows up under a finger.
2693 + // Against a real selection, because three of the four bindings act on one
2694 + // and refusing an empty selection is the routes doing their job.
2695 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2696 + let store = Store::default();
2697 + let sync = Offline;
2698 + let files = FakeFiles::default();
2699 + let themes = themes();
2700 + let state = Panels {
2701 + config: &store,
2702 + sync: &sync,
2703 + files: &files,
2704 + export: &Idle,
2705 + detail: &Unfocused,
2706 + bulk: &bulk,
2707 + themes: &themes,
2708 + };
2709 +
2710 + for binding in super::help::chrome().bindings {
2711 + let path = binding
2712 + .action
2713 + .destination
2714 + .route()
2715 + .expect("a binding goes somewhere in the app");
2716 + let answered = router().handle(&state, Request::get(path.to_string()));
2717 + assert!(
2718 + answered.is_ok(),
2719 + "{} points at {path}, which answered {:?}",
2720 + binding.key,
2721 + answered.err(),
2722 + );
2723 + }
2724 + }
2725 +
2726 + #[test]
2727 + fn no_bound_key_is_one_a_text_field_would_want() {
2728 + // THE FINDING, asserted so it cannot be quietly undone. `pressed_binding`
2729 + // reads raw input before the screen draws and has no focus guard, so a bare
2730 + // letter bound here would be eaten out of the tag field, the rename pattern
2731 + // and the search box. The shipped app's `handle_keyboard` opens with
2732 + // exactly that guard.
2733 + //
2734 + // When the guard exists in the runtime, this test is what should be deleted
2735 + // and the other sixteen shortcuts declared.
2736 + for binding in super::help::chrome().bindings {
2737 + let key = &binding.key;
2738 + let safe = key.starts_with('f') && key[1..].chars().all(|part| part.is_ascii_digit())
2739 + || key.contains("ctrl+")
2740 + || key.contains("cmd+");
2741 + assert!(safe, "{key} would be stolen from a text field");
2742 + }
2743 + }
2744 +
2745 + #[test]
2746 + fn the_help_overlay_is_drawn_over_what_is_showing() {
2747 + let response = helping(Request::get("/help")).unwrap();
2748 + assert!(matches!(response.outcome, Outcome::Over(_)));
2749 + }
2750 +
2751 + #[test]
2752 + fn switching_tabs_answers_a_fragment_so_the_overlay_survives() {
2753 + // Second consumer of the overlay-refresh finding, and a sharper one than
2754 + // the rename preview: a tabbed overlay is not buildable at all without
2755 + // fragments, because both outcomes that carry a screen destroy the layer.
2756 + let response = helping(
2757 + Request::post("/help/tab")
2758 + .sending(Params::new().with(Node::SELECTED.to_owned(), "features".to_owned())),
2759 + )
2760 + .unwrap();
2761 +
2762 + let Outcome::Fragment { region, node } = &response.outcome else {
2763 + panic!("expected a fragment, got {:?}", response.outcome);
2764 + };
2765 + assert_eq!(region, "help-tab");
2766 + // The features tab is a document, so it is markdown source rather than a
2767 + // tree of headings the description would have to invent structure for.
2768 + assert!(matches!(node, Node::Rich { .. }));
2769 + }
2770 +
2771 + #[test]
2772 + fn a_tab_that_is_not_one_of_the_two_is_refused() {
2773 + assert!(
2774 + helping(
2775 + Request::post("/help/tab")
2776 + .sending(Params::new().with(Node::SELECTED.to_owned(), "elsewhere".to_owned())),
2777 + )
2778 + .is_err()
2779 + );
2780 + }
2781 +
2782 + #[test]
2783 + fn the_shortcuts_tab_is_what_a_bare_help_request_answers() {
2784 + let response = helping(Request::get("/help")).unwrap();
2785 + let screen = overlay(&response);
2786 +
2787 + let chosen = nodes(screen).iter().find_map(|node| match node {
2788 + Node::Select { chosen, .. } => chosen.clone(),
2789 + _ => None,
2790 + });
2791 + assert_eq!(chosen.as_deref(), Some("shortcuts"));
2792 + }
@@ -1,0 +1,254 @@
1 + //! The help overlay, described rather than built, and the app's chrome beside it.
2 + //!
3 + //! The seventh audiofiles port, and the second untested surface it reaches:
4 + //! `Runtime::with_chrome` had never been called by this port, so
5 + //! [`Chrome`](quasi_router::Chrome) was written, answered by the renderer, and
6 + //! consumed by nothing.
7 + //!
8 + //! # The whole point: the table exists once
9 + //!
10 + //! `Binding`'s own header says what this file is for — "a help overlay that
11 + //! lists the bindings is otherwise a second, hand-written copy of them, free to
12 + //! drift from what the keys actually do" — and `ui::overlays::draw_shortcuts_tab`
13 + //! is that second copy, twenty-six rows of it, in seven hand-grouped arrays. The
14 + //! keys it names are handled in `editor::handle_keyboard`, several hundred lines
15 + //! away, and nothing checks that the two agree.
16 + //!
17 + //! Here [`chrome`] is the only table. The host binds it, so the keys work; the
18 + //! help screen lists it, so the help is what the keys are. Neither reads the
19 + //! other's copy because there is not one.
20 + //!
21 + //! # THE FINDING: a global binding steals what is being typed
22 + //!
23 + //! **The table below is short, and that is the finding rather than the scope.**
24 + //! Sixteen of the shipped app's twenty-six shortcuts are bare letters — `d`,
25 + //! `e`, `f`, `i`, `l`, `s`, `j`, `k`, `/`, `Space` — and not one of them can be
26 + //! declared here, because `Runtime::pressed_binding` reads raw input before the
27 + //! screen is drawn and nothing says "not while a field has the caret".
28 + //!
29 + //! The shipped app's `handle_keyboard` opens with the guard and a comment
30 + //! saying why:
31 + //!
32 + //! ```text
33 + //! // Don't handle keyboard shortcuts if a text field has focus
34 + //! if ctx.memory(|m| m.focused().is_some()) {
35 + //! ```
36 + //!
37 + //! So a described audiofiles that declared its real shortcuts would eat every
38 + //! letter typed into the tag field, the rename pattern and the search box. What
39 + //! is declared below is the safe remainder: function keys and command chords,
40 + //! which no text field wants. Filed rather than worked around, and the shape of
41 + //! the fix is in the app already — a binding is matched against the keyboard,
42 + //! and a keyboard that is answering a text field is not offering that key.
43 + //!
44 + //! # Two more things this screen could not say
45 + //!
46 + //! - **A `Binding` has no group.** The shipped tab sorts twenty-six rows into
47 + //! Navigation, Selection, Bulk, Search, Discovery, Toggles and System, which
48 + //! for a list that long is the difference between a reference and a wall. The
49 + //! described one is flat. Not filed on its own: the fix is a field, and it
50 + //! only starts mattering when the guard above lets the other sixteen exist.
51 + //! - **An action cannot sit inside a sentence.** The features tab writes "Use
52 + //! `/` to focus the search bar" with `/` as a live link that closes the help
53 + //! and focuses the field. `Node::Link` is a leaf and prose is a `Node::Text`,
54 + //! so a run of prose with a control in the middle of it is two nodes here and
55 + //! reads as one sentence cut in half. The links are dropped rather than faked;
56 + //! what they did is said in words.
57 + //!
58 + //! # And a second consumer for the overlay-refresh finding
59 + //!
60 + //! The shipped help has two tabs. Switching one inside an overlay cannot answer
61 + //! a screen — that clears the layer stack — and cannot answer `Over` again —
62 + //! that stacks a second copy. So the tab body is its own region and the switch
63 + //! answers `Outcome::Fragment`, which is the same shape the rename preview
64 + //! landed on. Two consumers now for `63cb3462`: **a tabbed overlay is not
65 + //! buildable without fragments, and nothing says so.**
66 +
67 + use quasi_router::layout::Selector;
68 + use quasi_router::{
69 + Action, Cell, Cells, Choice, Chrome, Column, Node, Outcome, RegionKind, Request, Response,
70 + RouteError, Router, Screen, Slot,
71 + };
72 +
73 + use super::Panels;
74 +
75 + /// The region the overlay answers into.
76 + const BODY: &str = "help-body";
77 + /// The region a tab's contents land in.
78 + const TAB: &str = "help-tab";
79 +
80 + /// Which tab is showing.
81 + const SHORTCUTS: &str = "shortcuts";
82 + /// The other one.
83 + const FEATURES: &str = "features";
84 +
85 + /// The keys that work from every described screen.
86 + ///
87 + /// **The only table.** [`routes`] lists it and [`panel`](super::panel) binds it,
88 + /// so what the help says and what the keys do cannot disagree. See this module's
89 + /// header for why it is four rows and not twenty-six.
90 + ///
91 + /// Every action is an address this router serves, which is the other half of
92 + /// "cannot disagree": a binding pointing at a route that does not exist would be
93 + /// a `NotFound` the first time it was pressed rather than a lie in a table.
94 + #[must_use]
95 + pub fn chrome() -> Chrome {
96 + Chrome::new()
97 + .bind("f1", "Show this help", Action::get("/help"))
98 + .bind("f2", "Rename the selection", Action::get("/bulk/rename"))
99 + .bind("ctrl+t", "Tag the selection", Action::get("/bulk/tag"))
100 + .bind(
101 + "ctrl+shift+m",
102 + "Move the selection",
103 + Action::get("/bulk/move"),
104 + )
105 + }
106 +
107 + /// Register the help overlay's routes.
108 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
109 + router.get("/help", index).post("/help/tab", tab)
110 + }
111 +
112 + /// `GET /help`
113 + fn index(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
114 + Ok(Response::over(screen(SHORTCUTS)))
115 + }
116 +
117 + /// `POST /help/tab`
118 + ///
119 + /// A fragment, because this overlay is already open. See the header: neither
120 + /// outcome that carries a whole screen can replace one layer of a stack.
121 + fn tab(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
122 + let chosen = request.payload.get(Node::SELECTED).unwrap_or(SHORTCUTS);
123 + if chosen != SHORTCUTS && chosen != FEATURES {
124 + return Err(RouteError::not_found("no such tab"));
125 + }
126 + Ok(Response::from(Outcome::Fragment {
127 + region: TAB.to_owned(),
128 + node: showing(chosen),
129 + }))
130 + }
131 +
132 + /// The overlay.
133 + fn screen(chosen: &str) -> Screen {
134 + let body = Slot::new(BODY, RegionKind::Pane)
135 + .with(Node::page("audiofiles"))
136 + .with(Node::Select {
137 + kind: Selector::Tabs,
138 + options: vec![
139 + (Choice::new(SHORTCUTS, "Shortcuts"), None),
140 + (Choice::new(FEATURES, "Features"), None),
141 + ],
142 + chosen: Some(chosen.to_owned()),
143 + action: Some(Action::post("/help/tab")),
144 + })
145 + .with(Node::Region(
146 + Slot::new(TAB, RegionKind::Group).with(showing(chosen)),
147 + ));
148 +
149 + Screen::sidebar_content("Help").with(body)
150 + }
151 +
152 + /// Whichever tab is chosen.
153 + fn showing(chosen: &str) -> Node {
154 + if chosen == FEATURES {
155 + features()
156 + } else {
157 + shortcuts()
158 + }
159 + }
160 +
161 + /// Every key that works, read off the one table.
162 + ///
163 + /// No filter box. The shipped tab has one because twenty-six rows in a
164 + /// fixed-height scroll area need it; narrowing a list a screen was handed is
165 + /// what a host does, which is the rule the bulk port's tag completions and
166 + /// folder filter both follow.
167 + fn shortcuts() -> Node {
168 + Node::Table {
169 + columns: vec![Column::new("Key"), Column::new("Does")],
170 + rows: chrome()
171 + .bindings
172 + .iter()
173 + .map(|binding| {
174 + Cells::new(vec![Cell::new(&binding.key), Cell::new(&binding.label)])
175 + .activate(binding.action.clone())
176 + })
177 + .collect(),
178 + }
179 + }
180 +
181 + /// What the app does, in prose.
182 + ///
183 + /// One `Node::Rich` rather than nine headings and nine paragraphs, because it is
184 + /// a document: markdown source is what `Rich` carries and every renderer turns
185 + /// it into its own markup, which is the member's whole argument. The shipped tab
186 + /// builds the same thing out of `ui.heading` and `ui.label` calls, so the
187 + /// structure is there and is not written down anywhere a renderer can read.
188 + fn features() -> Node {
189 + Node::rich(FEATURES_MD)
190 + }
191 +
192 + /// The features tab, as the document it is.
193 + ///
194 + /// Taken from `ui::overlays::draw_features_tab` with its three live links
195 + /// written out in words: see this module's header on why an action cannot sit
196 + /// inside a sentence.
197 + const FEATURES_MD: &str = "\
198 + ## Search and filter
199 +
200 + Press `/` to focus the search bar. Filter by BPM range, duration, loudness, key \
201 + and tags from the filter panel. Save any filter combination as a dynamic \
202 + collection.
203 +
204 + ## Collections
205 +
206 + Manual collections: right-click samples, then Add to Collection. Dynamic \
207 + collections: set filters, then click Save. A dynamic collection updates itself \
208 + when new samples match.
209 +
210 + ## Tags
211 +
212 + Use dot notation for hierarchy: `drums.kick`, `genre.house`. Filter by tag in \
213 + the sidebar tag tree, and tag a whole selection at once with `Ctrl+T`. Tag \
214 + suggestions appear in the detail panel, drawn from similar samples you have \
215 + already tagged.
216 +
217 + ## Import
218 +
219 + Quick Import indexes and analyses a whole folder. Files stay where they are \
220 + rather than being copied, and duplicates are skipped by content hash.
221 +
222 + ## Export
223 +
224 + Export to hardware samplers with device profiles: SP-404, Digitakt, MPC and the \
225 + rest. A profile sets the format, sample rate and naming rules for you. Or export \
226 + manually with your own settings.
227 +
228 + ## Instrument and MIDI
229 +
230 + The instrument panel plays a sample chromatically. Right-click a sample, then \
231 + Play as Instrument, to load it; right-click a key to set the root note. Connect \
232 + a MIDI controller for external playback.
233 +
234 + ## Sample editor
235 +
236 + The editor trims, normalises to peak or LUFS, applies gain, reverses, and fades \
237 + in or out. Select several samples to normalise, gain or reverse them together. \
238 + Its result mode decides whether the original is replaced or a sibling is made.
239 +
240 + ## Drag and drop
241 +
242 + Drag samples from the file list straight into your DAW or your file manager. \
243 + Drop audio files or folders onto the window to import them.
244 +
245 + ## Cloud sync
246 +
247 + Sync metadata -- tags and organisation -- across devices. Metadata sync is free; \
248 + syncing the sample files themselves is tiered by storage.
249 +
250 + ## System tray
251 +
252 + audiofiles keeps running in the tray when the window closes. Playback continues \
253 + while it is there.
254 + ";