//! The help overlay, described rather than built, and the app's chrome beside it. //! //! The seventh audiofiles port, and the second untested surface it reaches: //! `Runtime::with_chrome` had never been called by this port, so //! [`Chrome`](quasi_router::Chrome) was written, answered by the renderer, and //! consumed by nothing. //! //! # The whole point: the table exists once //! //! `Binding`'s own header says what this file is for — "a help overlay that //! lists the bindings is otherwise a second, hand-written copy of them, free to //! drift from what the keys actually do" — and `ui::overlays::draw_shortcuts_tab` //! is that second copy, twenty-six rows of it, in seven hand-grouped arrays. The //! keys it names are handled in `editor::handle_keyboard`, several hundred lines //! away, and nothing checks that the two agree. //! //! Here [`chrome`] is the only table. The host binds it, so the keys work; the //! help screen lists it, so the help is what the keys are. Neither reads the //! other's copy because there is not one. //! //! # THE FINDING, and what it looks like answered //! //! This table was four rows for five days, and the four were function keys and //! command chords: `Runtime::pressed_binding` read raw input before the screen //! was drawn with no focus guard, so a bare letter declared here would have been //! eaten out of the tag field, the rename pattern and the search box. Sixteen of //! the shipped app's twenty-six shortcuts are bare letters, and none of them //! could be said. //! //! quasi-immediate 0.52.0 (`bc5528e2`) put the guard where the finding said it //! belonged — in the renderer, not in each description — and the letters below //! are what it bought. A box with the caret answers its own keys; ctrl, alt and //! command produce no character, so those keep working mid-word, which the //! shipped app's broader "anything focused" rule would have lost. //! //! It also settled a second thing nobody could see while bare keys were //! undeclarable: `f` and `shift+f` are two entries, and the renderer matched //! them with `matches_logically`, which ignores a shift the pattern never asked //! for. The bare one answered both. Exact matching now, and it is this table //! that has the collision — the forge and Find similar. //! //! # What is still not here, and why //! //! Nine of the sixteen are declared. The rest are not the guard's business: //! //! - **`/` to focus the search box.** Focus is the renderer's, and nothing in //! the vocabulary says "put the caret in that field". The one shortcut here //! whose absence is a gap rather than a shape. //! - **`j`, `k`, Enter, Backspace, Space.** Walking a list and playing what is //! under the cursor. egui's reach already walks its own widgets, and a second //! party moving the keyboard is what `quasi_immediate::runtime`'s header says //! this renderer does not do. //! - **Delete, and `cmd+A`.** Both act on the selection, and a `Binding` carries //! an address with no payload. The bulk acts that need the ticked set reach it //! through their own screens. //! //! # One more thing this screen could not say //! //! - **A `Binding` had no group, and now has one.** The shipped tab sorts //! twenty-six rows into Navigation, Selection, Bulk, Search, Discovery, //! Toggles and System, which for a list that long is the difference between a //! reference and a wall. The described one was flat, and at thirteen rows it //! started to want them. `cf7872dc` answered it: `Chrome::bind_in` says the //! heading and `Chrome::grouped` gathers the runs, so the table below is four //! sections in the order this file bound them. Three of the shipped headings //! have no rows here, which is the nine-of-sixteen above rather than anything //! the member cannot say. //! - **An action cannot sit inside a sentence.** The features tab writes "Use //! `/` to focus the search bar" with `/` as a live link that closes the help //! and focuses the field. `Node::Link` is a leaf and prose is a `Node::Text`, //! so a run of prose with a control in the middle of it is two nodes here and //! reads as one sentence cut in half. The links are dropped rather than faked; //! what they did is said in words. //! //! # And a second consumer for the overlay-refresh finding //! //! The shipped help has two tabs. Switching one inside an overlay cannot answer //! a screen — that clears the layer stack — and cannot answer `Over` again — //! that stacks a second copy. So the tab body is its own region and the switch //! answers `Outcome::Fragment`, which is the same shape the rename preview //! landed on. Two consumers now for `63cb3462`: **a tabbed overlay is not //! buildable without fragments, and nothing says so.** use quasi_router::layout::Selector; use quasi_router::{ Action, Cell, Cells, Choice, Chrome, Column, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::{Panel, Panels}; /// The region the overlay answers into. const BODY: &str = "help-body"; /// The region a tab's contents land in. const TAB: &str = "help-tab"; /// The region the grouped key tables sit in. /// /// A region rather than a bare run because a tab's contents are one node, and /// grouping turned this tab from one table into a heading and a table per /// group. const SHORTCUTS_BODY: &str = "help-shortcuts"; /// Which tab is showing. const SHORTCUTS: &str = "shortcuts"; /// The other one. const FEATURES: &str = "features"; /// The keys that work from every described screen. /// /// **The only table.** [`routes`] lists it and [`panel`](super::panel) binds it, /// so what the help says and what the keys do cannot disagree. See this module's /// header for why it is thirteen rows and not twenty-six. /// /// Every action is an address this router serves, which is the other half of /// "cannot disagree": a binding pointing at a route that does not exist would be /// a `NotFound` the first time it was pressed rather than a lie in a table. #[must_use] pub fn chrome() -> Chrome { Chrome::new() // Four of the shipped tab's seven headings, in its order. `cf7872dc` // landed `Chrome::bind_in`, and the reason this table wanted it is the // reason the shipped one has arrays: thirteen rows read as a wall, and // a reader looking for the tagging key should not have to scan past the // panel toggles to find it. // // Navigation, Selection and Search have no rows here. See this module's // header for the seven shortcuts that are not the guard's business, and // for `/` -- the one absence that is a gap rather than a shape. .bind_in( BULK, "f2", "Rename the selection", Action::get("/bulk/rename"), ) .bind_in( BULK, "ctrl+t", "Tag the selection", Action::get("/bulk/tag"), ) .bind_in( BULK, "ctrl+shift+m", "Move the selection", Action::get("/bulk/move"), ) .bind_in( BULK, "ctrl+z", "Undo the last bulk action", Action::post("/undo"), ) // Shift's two. They were declared beside `f` while this table was flat, // because `shift+f` sitting directly behind it is what made the // renderer's matching exact (quasi-immediate 0.52.0); the grouping // separates them and the collision is unchanged, since matching reads // the key and not the neighbour. .bind_in( DISCOVERY, "shift+f", "Find similar samples", Action::post("/detail/similar"), ) .bind_in( DISCOVERY, "shift+d", "Find duplicates", Action::post("/detail/duplicates"), ) // The five panels the toolbar toggles, by the name an address is built // from. `Panel::as_str` is that name, so a panel renamed here and there // is one edit rather than two. .bind_in(TOGGLES, "s", "Toggle the sidebar", toggling(Panel::Sidebar)) .bind_in( TOGGLES, "d", "Toggle the detail panel", toggling(Panel::Detail), ) .bind_in( TOGGLES, "e", "Toggle the sample editor", toggling(Panel::Edit), ) .bind_in( TOGGLES, "i", "Toggle the instrument panel", toggling(Panel::Instrument), ) .bind_in(TOGGLES, "l", "Toggle loop", toggling(Panel::Loop)) // The forge is a screen rather than a panel, which is why this one is // not built the same way as the five above it. It is still a Toggle to // a reader, which is the whole reason the group is said rather than // derived from the address: `/panels/sidebar` and `/forge` are siblings // in nothing. .bind_in(TOGGLES, "f", "Open the sample forge", Action::get("/forge")) .bind_in(SYSTEM, "f1", "Show this help", Action::get("/help")) } /// The shipped tab's headings, for the four this table has rows under. /// /// Named rather than written at each call site: a heading spelt two ways is two /// groups, and the failure is a table that reads almost right. const BULK: &str = "Bulk"; /// Finding samples by what they are like. const DISCOVERY: &str = "Discovery"; /// Showing and hiding the app's own furniture. const TOGGLES: &str = "Toggles"; /// The app itself. const SYSTEM: &str = "System"; /// Toggling one panel, addressed by the name the panel answers to. /// /// Named for what it does rather than for its argument, so it does not read as /// a second [`panel`](super::panel) beside the host module of that name. fn toggling(panel: Panel) -> Action { Action::post(format!("/panels/{}", panel.as_str())) } /// Register the help overlay's routes. pub fn routes(router: Router>) -> Router> { router.get("/help", index).post("/help/tab", tab) } /// `GET /help` fn index(_state: &Panels<'_>, _request: Request) -> Result { Ok(Response::over(screen(SHORTCUTS))) } /// `POST /help/tab` /// /// A fragment, because this overlay is already open. See the header: neither /// outcome that carries a whole screen can replace one layer of a stack. fn tab(_state: &Panels<'_>, request: Request) -> Result { let chosen = request.payload.get(Node::SELECTED).unwrap_or(SHORTCUTS); if chosen != SHORTCUTS && chosen != FEATURES { return Err(RouteError::not_found("no such tab")); } Ok(Response::from(Outcome::Fragment { region: TAB.to_owned(), node: showing(chosen), })) } /// The overlay. fn screen(chosen: &str) -> Screen { let body = Slot::new(BODY, RegionKind::Pane) .with(Node::page("audiofiles")) .with(Node::Select { kind: Selector::Tabs, options: vec![ (Choice::new(SHORTCUTS, "Shortcuts"), None), (Choice::new(FEATURES, "Features"), None), ], chosen: Some(chosen.to_owned()), action: Some(Action::post("/help/tab")), }) .with(Node::Region( Slot::new(TAB, RegionKind::Group).with(showing(chosen)), )); Screen::sidebar_content("Help").with(body) } /// Whichever tab is chosen. fn showing(chosen: &str) -> Node { if chosen == FEATURES { features() } else { shortcuts() } } /// Every key that works, read off the one table. /// /// No filter box. The shipped tab has one because twenty-six rows in a /// fixed-height scroll area need it; narrowing a list a screen was handed is /// what a host does, which is the rule the bulk port's tag completions and /// folder filter both follow. fn shortcuts() -> Node { // A heading and a table per group, in the order the app bound them, which // is what `Chrome::grouped` answers. Not sorted here: the reading order is // the shipped tab's and belongs to whoever wrote the table. // // Several tables rather than one with a group column, because a group is a // heading and not a value: a column repeating "Toggles" six times says the // same thing six times and is still one wall. let mut sections = Vec::new(); for (group, bindings) in chrome().grouped() { // `None` is the ungrouped run, which this table has none of today and // would have again the moment somebody added a `bind`. Drawn without a // heading rather than under an invented one. if let Some(name) = group { sections.push(Node::section(name)); } sections.push(Node::Table { columns: vec![Column::new("Key"), Column::new("Does")], rows: bindings .iter() .map(|binding| { Cells::new(vec![Cell::new(&binding.key), Cell::new(&binding.label)]) .activate(binding.action.clone()) }) .collect(), // Every key that is bound is listed, which is the whole claim of // this screen. A shortcuts table with something withheld would be // the drift it exists to end. more: None, }); } let mut body = Slot::new(SHORTCUTS_BODY, RegionKind::Group); for node in sections { body = body.with(node); } Node::Region(body) } /// What the app does, in prose. /// /// One `Node::Rich` rather than nine headings and nine paragraphs, because it is /// a document: markdown source is what `Rich` carries and every renderer turns /// it into its own markup, which is the member's whole argument. The shipped tab /// builds the same thing out of `ui.heading` and `ui.label` calls, so the /// structure is there and is not written down anywhere a renderer can read. fn features() -> Node { Node::rich(FEATURES_MD) } /// The features tab, as the document it is. /// /// Taken from `ui::overlays::draw_features_tab` with its three live links /// written out in words: see this module's header on why an action cannot sit /// inside a sentence. const FEATURES_MD: &str = "\ ## Search and filter Press `/` to focus the search bar. Filter by BPM range, duration, loudness, key \ and tags from the filter panel. Save any filter combination as a dynamic \ collection. ## Collections Manual collections: right-click samples, then Add to Collection. Dynamic \ collections: set filters, then click Save. A dynamic collection updates itself \ when new samples match. ## Tags Use dot notation for hierarchy: `drums.kick`, `genre.house`. Filter by tag in \ the sidebar tag tree, and tag a whole selection at once with `Ctrl+T`. Tag \ suggestions appear in the detail panel, drawn from similar samples you have \ already tagged. ## Import Quick Import indexes and analyses a whole folder. Files stay where they are \ rather than being copied, and duplicates are skipped by content hash. ## Export Export to hardware samplers with device profiles: SP-404, Digitakt, MPC and the \ rest. A profile sets the format, sample rate and naming rules for you. Or export \ manually with your own settings. ## Instrument and MIDI The instrument panel plays a sample chromatically. Right-click a sample, then \ Play as Instrument, to load it; right-click a key to set the root note. Connect \ a MIDI controller for external playback. ## Sample editor The editor trims, normalises to peak or LUFS, applies gain, reverses, and fades \ in or out. Select several samples to normalise, gain or reverse them together. \ Its result mode decides whether the original is replaced or a sibling is made. ## Drag and drop Drag samples from the file list straight into your DAW or your file manager. \ Drop audio files or folders onto the window to import them. ## Cloud sync Sync metadata -- tags and organisation -- across devices. Metadata sync is free; \ syncing the sample files themselves is tiered by storage. ## System tray audiofiles keeps running in the tray when the window closes. Playback continues \ while it is there. ";