Skip to main content

max / audiofiles

Declare the nine shortcuts the focus guard made sayable quasi-immediate 0.52.0 carries the guard bc5528e2 asked for, so a bare letter bound in chrome no longer comes out of the tag field, the rename pattern or the search box. The help table was four rows for five days on account of that; it is thirteen now. ctrl+z for undo, the five panel toggles by Panel::as_str so a rename here is one edit rather than two, the forge, and shift's two discovery keys. Seven of the sixteen stay unsaid and the header says why: / needs a "put the caret there" the vocabulary does not have, j/k/Enter/Space are egui's own reach, and Delete and cmd+A act on a selection a Binding cannot carry. f and shift+f are the collision that made the renderer's matching exact, and they are in this table -- the forge and Find similar. no_bound_key_is_one_a_text_field_would_want asserted the restriction and named itself as the thing to delete once the guard existed, so it is deleted. every_bound_key_points_at_an_address_this_router_serves replaces calling each address with reading the router's route table: nine of the thirteen are writes, and a live route refusing an inert fixture answers NotFound, which is indistinguishable from an address nobody serves.
Author: Max Johnson <me@maxj.phd> · 2026-08-22 03:32 UTC
Signed with PGP, not checked
Commit: bbf06e8257ebdfd1cb08168bb280310679043078
Parent: a811d5d
4 files changed, +149 insertions, -96 deletions
M Cargo.lock +22 -24
@@ -3058,6 +3058,8 @@
3058 3058 [[package]]
3059 3059 name = "makeover"
3060 3060 version = "3.0.0"
3061 + source = "registry+https://github.com/rust-lang/crates.io-index"
3062 + checksum = "3e6da78c376074e48eb2324394004afc847e674e05ed2ea4bec1331073990b3f"
3061 3063 dependencies = [
3062 3064 "include_dir",
3063 3065 "serde",
@@ -3067,10 +3069,14 @@
3067 3069 [[package]]
3068 3070 name = "makeover-geometry"
3069 3071 version = "0.7.0"
3072 + source = "registry+https://github.com/rust-lang/crates.io-index"
3073 + checksum = "91a82b16c4f5b66dd7e87ed92fff7d22185e4d88e5355de6166dd64450cf6a16"
3070 3074
3071 3075 [[package]]
3072 3076 name = "makeover-immediate"
3073 3077 version = "0.33.0"
3078 + source = "registry+https://github.com/rust-lang/crates.io-index"
3079 + checksum = "43d67f743414e6b1ffcce6da7e9500cd2d4efe5cf42d730ae8f6e6c8d87c3d5a"
3074 3080 dependencies = [
3075 3081 "egui",
3076 3082 "egui_extras",
@@ -3080,10 +3086,14 @@
3080 3086 [[package]]
3081 3087 name = "makeover-layout"
3082 3088 version = "0.35.0"
3089 + source = "registry+https://github.com/rust-lang/crates.io-index"
3090 + checksum = "774bfd81b003f0d0059ff50daa8e625e6969175bc0132ddf7c2108bba40c47d5"
3083 3091
3084 3092 [[package]]
3085 3093 name = "makeover-timing"
3086 3094 version = "0.1.0"
3095 + source = "registry+https://github.com/rust-lang/crates.io-index"
3096 + checksum = "3457a82ddd617b6c0ee19bb6f6419d296753e6e9fcb6e94b88da33d4d67a97b3"
3087 3097 dependencies = [
3088 3098 "makeover-geometry",
3089 3099 ]
@@ -4245,7 +4255,7 @@
4245 4255
4246 4256 [[package]]
4247 4257 name = "quasi-immediate"
4248 - version = "0.49.0"
4258 + version = "0.52.0"
4249 4259 dependencies = [
4250 4260 "docengine",
4251 4261 "egui",
@@ -4255,7 +4265,7 @@
4255 4265
4256 4266 [[package]]
4257 4267 name = "quasi-router"
4258 - version = "0.49.0"
4268 + version = "0.52.0"
4259 4269 dependencies = [
4260 4270 "makeover-layout",
4261 4271 ]
@@ -7544,6 +7554,10 @@
7544 7554 "winnow 1.0.4",
7545 7555 ]
7546 7556
7557 + [[patch.unused]]
7558 + name = "quasi-type"
7559 + version = "0.1.0"
7560 +
7547 7561 [[patch.unused]]
7548 7562 name = "kberg"
7549 7563 version = "0.1.0"
@@ -7556,37 +7570,21 @@
7556 7570 name = "painhours"
7557 7571 version = "0.1.0"
7558 7572
7559 - [[patch.unused]]
7560 - name = "makeover-touch"
7561 - version = "0.24.0"
7562 -
7563 - [[patch.unused]]
7564 - name = "makeover-tui"
7565 - version = "0.33.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "makeover-webview"
7569 - version = "0.56.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-type"
7573 - version = "0.1.0"
7574 -
7575 7573 [[patch.unused]]
7576 7574 name = "quasi-axum"
7577 - version = "0.49.0"
7575 + version = "0.52.0"
7578 7576
7579 7577 [[patch.unused]]
7580 7578 name = "quasi-basics"
7581 - version = "0.49.0"
7579 + version = "0.52.0"
7582 7580
7583 7581 [[patch.unused]]
7584 7582 name = "quasi-http"
7585 - version = "0.49.0"
7583 + version = "0.52.0"
7586 7584
7587 7585 [[patch.unused]]
7588 7586 name = "quasi-notifs"
7589 - version = "0.49.0"
7587 + version = "0.52.0"
7590 7588
7591 7589 [[patch.unused]]
7592 7590 name = "quasi-store"
@@ -7594,8 +7592,8 @@
7594 7592
7595 7593 [[patch.unused]]
7596 7594 name = "quasi-tauri"
7597 - version = "0.49.0"
7595 + version = "0.52.0"
7598 7596
7599 7597 [[patch.unused]]
7600 7598 name = "quasi-webview"
7601 - version = "0.49.0"
7599 + version = "0.52.0"
M Cargo.toml +2 -2
@@ -25,8 +25,8 @@
25 25 makeover-timing = "0.1"
26 26 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
27 27 # with a version requirement, per the tree's rule for cross-repo deps.
28 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.49" }
29 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.49" }
28 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.52" }
29 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.52" }
30 30 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
31 31 egui_extras = { version = "0.35", default-features = false }
32 32 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -18,36 +18,48 @@
18 18 //! help screen lists it, so the help is what the keys are. Neither reads the
19 19 //! other's copy because there is not one.
20 20 //!
21 - //! # THE FINDING: a global binding steals what is being typed
21 + //! # THE FINDING, and what it looks like answered
22 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".
23 + //! This table was four rows for five days, and the four were function keys and
24 + //! command chords: `Runtime::pressed_binding` read raw input before the screen
25 + //! was drawn with no focus guard, so a bare letter declared here would have been
26 + //! eaten out of the tag field, the rename pattern and the search box. Sixteen of
27 + //! the shipped app's twenty-six shortcuts are bare letters, and none of them
28 + //! could be said.
28 29 //!
29 - //! The shipped app's `handle_keyboard` opens with the guard and a comment
30 - //! saying why:
30 + //! quasi-immediate 0.52.0 (`bc5528e2`) put the guard where the finding said it
31 + //! belonged — in the renderer, not in each description — and the letters below
32 + //! are what it bought. A box with the caret answers its own keys; ctrl, alt and
33 + //! command produce no character, so those keep working mid-word, which the
34 + //! shipped app's broader "anything focused" rule would have lost.
31 35 //!
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 + //! It also settled a second thing nobody could see while bare keys were
37 + //! undeclarable: `f` and `shift+f` are two entries, and the renderer matched
38 + //! them with `matches_logically`, which ignores a shift the pattern never asked
39 + //! for. The bare one answered both. Exact matching now, and it is this table
40 + //! that has the collision — the forge and Find similar.
36 41 //!
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.
42 + //! # What is still not here, and why
43 43 //!
44 - //! # Two more things this screen could not say
44 + //! Nine of the sixteen are declared. The rest are not the guard's business:
45 + //!
46 + //! - **`/` to focus the search box.** Focus is the renderer's, and nothing in
47 + //! the vocabulary says "put the caret in that field". The one shortcut here
48 + //! whose absence is a gap rather than a shape.
49 + //! - **`j`, `k`, Enter, Backspace, Space.** Walking a list and playing what is
50 + //! under the cursor. egui's reach already walks its own widgets, and a second
51 + //! party moving the keyboard is what `quasi_immediate::runtime`'s header says
52 + //! this renderer does not do.
53 + //! - **Delete, and `cmd+A`.** Both act on the selection, and a `Binding` carries
54 + //! an address with no payload. The bulk acts that need the ticked set reach it
55 + //! through their own screens.
56 + //!
57 + //! # One more thing this screen could not say
45 58 //!
46 59 //! - **A `Binding` has no group.** The shipped tab sorts twenty-six rows into
47 60 //! Navigation, Selection, Bulk, Search, Discovery, Toggles and System, which
48 61 //! 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.
62 + //! described one is flat, and at thirteen rows it has started to want them.
51 63 //! - **An action cannot sit inside a sentence.** The features tab writes "Use
52 64 //! `/` to focus the search bar" with `/` as a live link that closes the help
53 65 //! and focuses the field. `Node::Link` is a leaf and prose is a `Node::Text`,
@@ -70,7 +82,7 @@
70 82 RouteError, Router, Screen, Slot,
71 83 };
72 84
73 - use super::Panels;
85 + use super::{Panel, Panels};
74 86
75 87 /// The region the overlay answers into.
76 88 const BODY: &str = "help-body";
@@ -86,7 +98,7 @@
86 98 ///
87 99 /// **The only table.** [`routes`] lists it and [`panel`](super::panel) binds it,
88 100 /// 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.
101 + /// header for why it is thirteen rows and not twenty-six.
90 102 ///
91 103 /// Every action is an address this router serves, which is the other half of
92 104 /// "cannot disagree": a binding pointing at a route that does not exist would be
@@ -102,6 +114,42 @@
102 114 "Move the selection",
103 115 Action::get("/bulk/move"),
104 116 )
117 + .bind("ctrl+z", "Undo the last bulk action", Action::post("/undo"))
118 + // The five panels the toolbar toggles, by the name an address is built
119 + // from. `Panel::as_str` is that name, so a panel renamed here and there
120 + // is one edit rather than two.
121 + .bind("s", "Toggle the sidebar", toggling(Panel::Sidebar))
122 + .bind("d", "Toggle the detail panel", toggling(Panel::Detail))
123 + .bind("e", "Toggle the sample editor", toggling(Panel::Edit))
124 + .bind(
125 + "i",
126 + "Toggle the instrument panel",
127 + toggling(Panel::Instrument),
128 + )
129 + .bind("l", "Toggle loop", toggling(Panel::Loop))
130 + // The forge is a screen rather than a panel, which is why this one is
131 + // not built the same way as the five above it.
132 + .bind("f", "Open the sample forge", Action::get("/forge"))
133 + // Shift's two, and the pair that made the renderer's matching exact:
134 + // `shift+f` sits directly behind `f` in this table.
135 + .bind(
136 + "shift+f",
137 + "Find similar samples",
138 + Action::post("/detail/similar"),
139 + )
140 + .bind(
141 + "shift+d",
142 + "Find duplicates",
143 + Action::post("/detail/duplicates"),
144 + )
145 + }
146 +
147 + /// Toggling one panel, addressed by the name the panel answers to.
148 + ///
149 + /// Named for what it does rather than for its argument, so it does not read as
150 + /// a second [`panel`](super::panel) beside the host module of that name.
151 + fn toggling(panel: Panel) -> Action {
152 + Action::post(format!("/panels/{}", panel.as_str()))
105 153 }
106 154
107 155 /// Register the help overlay's routes.
@@ -3443,66 +3443,73 @@
3443 3443 // The other half of "cannot disagree": a binding naming a route that does
3444 3444 // not exist would be a NotFound the first time it was pressed, which is a
3445 3445 // lie in a table that only shows up under a finger.
3446 - // Against a real selection, because three of the four bindings act on one
3447 - // and refusing an empty selection is the routes doing their job.
3448 - let bulk = FakeBulk::of(&["kick.wav"], 1);
3449 - let store = Store::default();
3450 - let sync = Offline;
3451 - let files = FakeFiles::default();
3452 - let themes = themes();
3453 - let state = Panels {
3454 - config: &store,
3455 - sync: &sync,
3456 - files: &files,
3457 - export: &Idle,
3458 - detail: &Unfocused,
3459 - bulk: &bulk,
3460 - shell: &Quiet,
3461 - library: &Empty,
3462 - bar: &Still,
3463 - naming: &Unnamed,
3464 - importing: &NoImport,
3465 - integrity: &Sound,
3466 - editor: &Unedited,
3467 - forge: &Unforged,
3468 - queue: &Unqueued,
3469 - filters: &Unfiltered,
3470 - themes: &themes,
3471 - };
3472 -
3446 + //
3447 + // Asked of the route table rather than by calling each address, and the
3448 + // difference started mattering when the table grew past the four safe keys.
3449 + // A live route refuses a state it cannot act in -- `/undo` with an empty
3450 + // stack, `/detail/similar` with nothing analysed -- and both refuse with
3451 + // `NotFound`, which is indistinguishable from an address nobody serves.
3452 + // Calling would have this test asserting that thirteen preconditions are
3453 + // satisfiable by one fixture, which is not what it is for.
3454 + let router = router();
3455 + let served: Vec<(Method, &str)> = router.routes().collect();
3473 3456 for binding in super::help::chrome().bindings {
3474 3457 let path = binding
3475 3458 .action
3476 3459 .destination
3477 3460 .route()
3478 3461 .expect("a binding goes somewhere in the app");
3479 - let answered = router().handle(&state, Request::get(path.to_string()));
3480 3462 assert!(
3481 - answered.is_ok(),
3482 - "{} points at {path}, which answered {:?}",
3463 + served
3464 + .iter()
3465 + .any(|(method, pattern)| *method == binding.action.method && covers(pattern, path)),
3466 + "{} points at {:?} {path}, which no route serves",
3483 3467 binding.key,
3484 - answered.err(),
3468 + binding.action.method,
3485 3469 );
3486 3470 }
3487 3471 }
3488 3472
3489 - #[test]
3490 - fn no_bound_key_is_one_a_text_field_would_want() {
3491 - // THE FINDING, asserted so it cannot be quietly undone. `pressed_binding`
3492 - // reads raw input before the screen draws and has no focus guard, so a bare
3493 - // letter bound here would be eaten out of the tag field, the rename pattern
3494 - // and the search box. The shipped app's `handle_keyboard` opens with
3495 - // exactly that guard.
3496 - //
3497 - // When the guard exists in the runtime, this test is what should be deleted
3498 - // and the other sixteen shortcuts declared.
3499 - for binding in super::help::chrome().bindings {
3500 - let key = &binding.key;
3501 - let safe = key.starts_with('f') && key[1..].chars().all(|part| part.is_ascii_digit())
3502 - || key.contains("ctrl+")
3503 - || key.contains("cmd+");
3504 - assert!(safe, "{key} would be stolen from a text field");
3473 + /// Whether a registered route pattern is the one this address lands on.
3474 + ///
3475 + /// The five panel keys address `/panels/sidebar` and the router holds
3476 + /// `/panels/{panel}`, so a string comparison answers no to a binding that works.
3477 + /// Segment counts and literals have to agree; a `{name}` segment takes whatever
3478 + /// is in its place, which is the only thing the router's own matching does that
3479 + /// matters here.
3480 + fn covers(pattern: &str, path: &str) -> bool {
3481 + let pattern = pattern.split('/');
3482 + let mut path = path.split('/');
3483 + for expected in pattern {
3484 + let Some(actual) = path.next() else {
3485 + return false;
3486 + };
3487 + let placeholder = expected.starts_with('{') && expected.ends_with('}');
3488 + if !placeholder && expected != actual {
3489 + return false;
3490 + }
3505 3491 }
3492 + path.next().is_none()
3493 + }
3494 +
3495 + #[test]
3496 + fn the_shifted_bindings_do_not_shadow_their_bare_twins() {
3497 + // `f`/`shift+f` and `d`/`shift+d`. The renderer matches exactly as of
3498 + // quasi-immediate 0.52.0, so these are four entries rather than two; before
3499 + // that the bare one answered both and this table is the app that found it.
3500 + let bindings = super::help::chrome().bindings;
3501 + let key = |wanted: &str| {
3502 + bindings
3503 + .iter()
3504 + .find(|binding| binding.key == wanted)
3505 + .unwrap_or_else(|| panic!("{wanted} is not bound"))
3506 + .action
3507 + .route()
3508 + .expect("bound to this app's own router")
3509 + .to_owned()
3510 + };
3511 + assert_ne!(key("f"), key("shift+f"));
3512 + assert_ne!(key("d"), key("shift+d"));
3506 3513 }
3507 3514
3508 3515 #[test]