|
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 |
+ |
";
|