max / audiofiles
4 files changed,
+149 insertions,
-96 deletions
| @@ -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" |
| @@ -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] |