Skip to main content

max / audiofiles

15.9 KB · 394 lines History Blame Raw
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, and what it looks like answered
22 //!
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.
29 //!
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.
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.
41 //!
42 //! # What is still not here, and why
43 //!
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
58 //!
59 //! - **A `Binding` had no group, and now has one.** The shipped tab sorts
60 //! twenty-six rows into Navigation, Selection, Bulk, Search, Discovery,
61 //! Toggles and System, which for a list that long is the difference between a
62 //! reference and a wall. The described one was flat, and at thirteen rows it
63 //! started to want them. `cf7872dc` answered it: `Chrome::bind_in` says the
64 //! heading and `Chrome::grouped` gathers the runs, so the table below is four
65 //! sections in the order this file bound them. Three of the shipped headings
66 //! have no rows here, which is the nine-of-sixteen above rather than anything
67 //! the member cannot say.
68 //! - **An action cannot sit inside a sentence.** The features tab writes "Use
69 //! `/` to focus the search bar" with `/` as a live link that closes the help
70 //! and focuses the field. `Node::Link` is a leaf and prose is a `Node::Text`,
71 //! so a run of prose with a control in the middle of it is two nodes here and
72 //! reads as one sentence cut in half. The links are dropped rather than faked;
73 //! what they did is said in words.
74 //!
75 //! # And a second consumer for the overlay-refresh finding
76 //!
77 //! The shipped help has two tabs. Switching one inside an overlay cannot answer
78 //! a screen — that clears the layer stack — and cannot answer `Over` again —
79 //! that stacks a second copy. So the tab body is its own region and the switch
80 //! answers `Outcome::Fragment`, which is the same shape the rename preview
81 //! landed on. Two consumers now for `63cb3462`: **a tabbed overlay is not
82 //! buildable without fragments, and nothing says so.**
83
84 use quasi_router::layout::Selector;
85 use quasi_router::{
86 Action, Cell, Cells, Choice, Chrome, Column, Node, Outcome, RegionKind, Request, Response,
87 RouteError, Router, Screen, Slot,
88 };
89
90 use super::{Panel, Panels};
91
92 /// The region the overlay answers into.
93 const BODY: &str = "help-body";
94 /// The region a tab's contents land in.
95 const TAB: &str = "help-tab";
96 /// The region the grouped key tables sit in.
97 ///
98 /// A region rather than a bare run because a tab's contents are one node, and
99 /// grouping turned this tab from one table into a heading and a table per
100 /// group.
101 const SHORTCUTS_BODY: &str = "help-shortcuts";
102
103 /// Which tab is showing.
104 const SHORTCUTS: &str = "shortcuts";
105 /// The other one.
106 const FEATURES: &str = "features";
107
108 /// The keys that work from every described screen.
109 ///
110 /// **The only table.** [`routes`] lists it and [`panel`](super::panel) binds it,
111 /// so what the help says and what the keys do cannot disagree. See this module's
112 /// header for why it is thirteen rows and not twenty-six.
113 ///
114 /// Every action is an address this router serves, which is the other half of
115 /// "cannot disagree": a binding pointing at a route that does not exist would be
116 /// a `NotFound` the first time it was pressed rather than a lie in a table.
117 #[must_use]
118 pub fn chrome() -> Chrome {
119 Chrome::new()
120 // Four of the shipped tab's seven headings, in its order. `cf7872dc`
121 // landed `Chrome::bind_in`, and the reason this table wanted it is the
122 // reason the shipped one has arrays: thirteen rows read as a wall, and
123 // a reader looking for the tagging key should not have to scan past the
124 // panel toggles to find it.
125 //
126 // Navigation, Selection and Search have no rows here. See this module's
127 // header for the seven shortcuts that are not the guard's business, and
128 // for `/` -- the one absence that is a gap rather than a shape.
129 .bind_in(
130 BULK,
131 "f2",
132 "Rename the selection",
133 Action::get("/bulk/rename"),
134 )
135 .bind_in(
136 BULK,
137 "ctrl+t",
138 "Tag the selection",
139 Action::get("/bulk/tag"),
140 )
141 .bind_in(
142 BULK,
143 "ctrl+shift+m",
144 "Move the selection",
145 Action::get("/bulk/move"),
146 )
147 .bind_in(
148 BULK,
149 "ctrl+z",
150 "Undo the last bulk action",
151 Action::post("/undo"),
152 )
153 // Shift's two. They were declared beside `f` while this table was flat,
154 // because `shift+f` sitting directly behind it is what made the
155 // renderer's matching exact (quasi-immediate 0.52.0); the grouping
156 // separates them and the collision is unchanged, since matching reads
157 // the key and not the neighbour.
158 .bind_in(
159 DISCOVERY,
160 "shift+f",
161 "Find similar samples",
162 Action::post("/detail/similar"),
163 )
164 .bind_in(
165 DISCOVERY,
166 "shift+d",
167 "Find duplicates",
168 Action::post("/detail/duplicates"),
169 )
170 // The five panels the toolbar toggles, by the name an address is built
171 // from. `Panel::as_str` is that name, so a panel renamed here and there
172 // is one edit rather than two.
173 .bind_in(TOGGLES, "s", "Toggle the sidebar", toggling(Panel::Sidebar))
174 .bind_in(
175 TOGGLES,
176 "d",
177 "Toggle the detail panel",
178 toggling(Panel::Detail),
179 )
180 .bind_in(
181 TOGGLES,
182 "e",
183 "Toggle the sample editor",
184 toggling(Panel::Edit),
185 )
186 .bind_in(
187 TOGGLES,
188 "i",
189 "Toggle the instrument panel",
190 toggling(Panel::Instrument),
191 )
192 .bind_in(TOGGLES, "l", "Toggle loop", toggling(Panel::Loop))
193 // The forge is a screen rather than a panel, which is why this one is
194 // not built the same way as the five above it. It is still a Toggle to
195 // a reader, which is the whole reason the group is said rather than
196 // derived from the address: `/panels/sidebar` and `/forge` are siblings
197 // in nothing.
198 .bind_in(TOGGLES, "f", "Open the sample forge", Action::get("/forge"))
199 .bind_in(SYSTEM, "f1", "Show this help", Action::get("/help"))
200 }
201
202 /// The shipped tab's headings, for the four this table has rows under.
203 ///
204 /// Named rather than written at each call site: a heading spelt two ways is two
205 /// groups, and the failure is a table that reads almost right.
206 const BULK: &str = "Bulk";
207 /// Finding samples by what they are like.
208 const DISCOVERY: &str = "Discovery";
209 /// Showing and hiding the app's own furniture.
210 const TOGGLES: &str = "Toggles";
211 /// The app itself.
212 const SYSTEM: &str = "System";
213
214 /// Toggling one panel, addressed by the name the panel answers to.
215 ///
216 /// Named for what it does rather than for its argument, so it does not read as
217 /// a second [`panel`](super::panel) beside the host module of that name.
218 fn toggling(panel: Panel) -> Action {
219 Action::post(format!("/panels/{}", panel.as_str()))
220 }
221
222 /// Register the help overlay's routes.
223 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
224 router.get("/help", index).post("/help/tab", tab)
225 }
226
227 /// `GET /help`
228 fn index(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
229 Ok(Response::over(screen(SHORTCUTS)))
230 }
231
232 /// `POST /help/tab`
233 ///
234 /// A fragment, because this overlay is already open. See the header: neither
235 /// outcome that carries a whole screen can replace one layer of a stack.
236 fn tab(_state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
237 let chosen = request.payload.get(Node::SELECTED).unwrap_or(SHORTCUTS);
238 if chosen != SHORTCUTS && chosen != FEATURES {
239 return Err(RouteError::not_found("no such tab"));
240 }
241 Ok(Response::from(Outcome::Fragment {
242 region: TAB.to_owned(),
243 node: showing(chosen),
244 }))
245 }
246
247 /// The overlay.
248 fn screen(chosen: &str) -> Screen {
249 let body = Slot::new(BODY, RegionKind::Pane)
250 .with(Node::page("audiofiles"))
251 .with(Node::Select {
252 kind: Selector::Tabs,
253 options: vec![
254 (Choice::new(SHORTCUTS, "Shortcuts"), None),
255 (Choice::new(FEATURES, "Features"), None),
256 ],
257 chosen: Some(chosen.to_owned()),
258 action: Some(Action::post("/help/tab")),
259 })
260 .with(Node::Region(
261 Slot::new(TAB, RegionKind::Group).with(showing(chosen)),
262 ));
263
264 Screen::sidebar_content("Help").with(body)
265 }
266
267 /// Whichever tab is chosen.
268 fn showing(chosen: &str) -> Node {
269 if chosen == FEATURES {
270 features()
271 } else {
272 shortcuts()
273 }
274 }
275
276 /// Every key that works, read off the one table.
277 ///
278 /// No filter box. The shipped tab has one because twenty-six rows in a
279 /// fixed-height scroll area need it; narrowing a list a screen was handed is
280 /// what a host does, which is the rule the bulk port's tag completions and
281 /// folder filter both follow.
282 fn shortcuts() -> Node {
283 // A heading and a table per group, in the order the app bound them, which
284 // is what `Chrome::grouped` answers. Not sorted here: the reading order is
285 // the shipped tab's and belongs to whoever wrote the table.
286 //
287 // Several tables rather than one with a group column, because a group is a
288 // heading and not a value: a column repeating "Toggles" six times says the
289 // same thing six times and is still one wall.
290 let mut sections = Vec::new();
291 for (group, bindings) in chrome().grouped() {
292 // `None` is the ungrouped run, which this table has none of today and
293 // would have again the moment somebody added a `bind`. Drawn without a
294 // heading rather than under an invented one.
295 if let Some(name) = group {
296 sections.push(Node::section(name));
297 }
298 sections.push(Node::Table {
299 columns: vec![Column::new("Key"), Column::new("Does")],
300 rows: bindings
301 .iter()
302 .map(|binding| {
303 Cells::new(vec![Cell::new(&binding.key), Cell::new(&binding.label)])
304 .activate(binding.action.clone())
305 })
306 .collect(),
307 // Every key that is bound is listed, which is the whole claim of
308 // this screen. A shortcuts table with something withheld would be
309 // the drift it exists to end.
310 more: None,
311 });
312 }
313 let mut body = Slot::new(SHORTCUTS_BODY, RegionKind::Group);
314 for node in sections {
315 body = body.with(node);
316 }
317 Node::Region(body)
318 }
319
320 /// What the app does, in prose.
321 ///
322 /// One `Node::Rich` rather than nine headings and nine paragraphs, because it is
323 /// a document: markdown source is what `Rich` carries and every renderer turns
324 /// it into its own markup, which is the member's whole argument. The shipped tab
325 /// builds the same thing out of `ui.heading` and `ui.label` calls, so the
326 /// structure is there and is not written down anywhere a renderer can read.
327 fn features() -> Node {
328 Node::rich(FEATURES_MD)
329 }
330
331 /// The features tab, as the document it is.
332 ///
333 /// Taken from `ui::overlays::draw_features_tab` with its three live links
334 /// written out in words: see this module's header on why an action cannot sit
335 /// inside a sentence.
336 const FEATURES_MD: &str = "\
337 ## Search and filter
338
339 Press `/` to focus the search bar. Filter by BPM range, duration, loudness, key \
340 and tags from the filter panel. Save any filter combination as a dynamic \
341 collection.
342
343 ## Collections
344
345 Manual collections: right-click samples, then Add to Collection. Dynamic \
346 collections: set filters, then click Save. A dynamic collection updates itself \
347 when new samples match.
348
349 ## Tags
350
351 Use dot notation for hierarchy: `drums.kick`, `genre.house`. Filter by tag in \
352 the sidebar tag tree, and tag a whole selection at once with `Ctrl+T`. Tag \
353 suggestions appear in the detail panel, drawn from similar samples you have \
354 already tagged.
355
356 ## Import
357
358 Quick Import indexes and analyses a whole folder. Files stay where they are \
359 rather than being copied, and duplicates are skipped by content hash.
360
361 ## Export
362
363 Export to hardware samplers with device profiles: SP-404, Digitakt, MPC and the \
364 rest. A profile sets the format, sample rate and naming rules for you. Or export \
365 manually with your own settings.
366
367 ## Instrument and MIDI
368
369 The instrument panel plays a sample chromatically. Right-click a sample, then \
370 Play as Instrument, to load it; right-click a key to set the root note. Connect \
371 a MIDI controller for external playback.
372
373 ## Sample editor
374
375 The editor trims, normalises to peak or LUFS, applies gain, reverses, and fades \
376 in or out. Select several samples to normalise, gain or reverse them together. \
377 Its result mode decides whether the original is replaced or a sibling is made.
378
379 ## Drag and drop
380
381 Drag samples from the file list straight into your DAW or your file manager. \
382 Drop audio files or folders onto the window to import them.
383
384 ## Cloud sync
385
386 Sync metadata -- tags and organisation -- across devices. Metadata sync is free; \
387 syncing the sample files themselves is tiered by storage.
388
389 ## System tray
390
391 audiofiles keeps running in the tray when the window closes. Playback continues \
392 while it is there.
393 ";
394