Skip to main content

max / audiofiles

17.5 KB · 413 lines History Blame Raw
1 //! The toolbar, described: where you are, what you are looking for, and what is
2 //! showing.
3 //!
4 //! The tenth port, and the last region of the main window. It is also the first
5 //! screen that *navigates to the others*: Settings, Cloud Sync and Help are
6 //! described already, so the toolbar's buttons for them are ordinary addresses
7 //! rather than intents. Up to here every described window was reached by the
8 //! host opening it; this is the port where the described app starts being one
9 //! app.
10 //!
11 //! # What the description deletes, and this is the largest single case yet
12 //!
13 //! **The search field measures the row it is in.** `draw_toolbar` keeps a
14 //! `trailing_width` in egui memory, reads it at the start of the frame to size
15 //! the field, measures what the trailing controls actually consumed at the end,
16 //! writes it back if it moved by more than half a pixel, and requests a repaint
17 //! so the correction lands. Two constants support it — a generous
18 //! `DEFAULT_TRAILING_WIDTH` for the first frame and a `MIN_SEARCH_WIDTH` floor —
19 //! and the whole apparatus exists to express one sentence: *the field takes
20 //! whatever the controls after it do not need*.
21 //!
22 //! Twenty lines, two constants, a persistent id and a one-frame lag go, and the
23 //! described row measures nothing: how a row of controls divides itself is the
24 //! host's, resolved in its own layout pass where the numbers actually are.
25 //!
26 //! **And it is now sayable.** `Width::Fill` existed on a table
27 //! [`Column`](quasi_router::Column) and `Share` on a region, so the vocabulary
28 //! already accepted that an app has opinions about which of several things
29 //! expands; a leaf control having no way to say it was an inconsistency rather
30 //! than a principle. Filed as `6d6a9160`, settled by Max the same day — *fill is
31 //! determined at the description stage* — and landed as
32 //! [`Field::width`](quasi_router::Field::width) in quasi 0.17.0. The search box
33 //! below says `Width::Fill` and the twenty lines are gone.
34 //!
35 //! **Two more pixel breakpoints.** `screen_w < 900.0` collapses six panel
36 //! toggles into a View menu; `screen_w < 700.0` marks the detail panel as
37 //! present-but-hidden. Same class as the footer's `1000.0`, and the same answer:
38 //! the description says what the controls are, and how many fit is the host's.
39 //! What is *not* renderer policy is the detail toggle's muted state, which says
40 //! "this is on but you cannot see it" — that is a fact about a window, so it is
41 //! not described here either, for the opposite reason.
42 //!
43 //! # THE FINDING: a field cannot say that firing it is expensive
44 //!
45 //! [`Field::changes`](quasi_router::Field::changes) names an address to call
46 //! when a value changes and says nothing about how often. The shipped search box
47 //! cannot afford per-keystroke, and its comment is explicit: "each keystroke
48 //! would otherwise run a blocking DB query + re-sort on the GUI thread". So it
49 //! carries a 150ms debounce, re-armed on change, with a `request_repaint_after`
50 //! to make the trailing edge land without further input.
51 //!
52 //! A described search field has no way to say that. `changes` fires, and how
53 //! often is the host's — which is right in the same way a fade timer is right,
54 //! and incomplete in a way a fade timer is not: getting a fade wrong is ugly and
55 //! getting this wrong is a blocking query per keystroke. Every renderer will
56 //! either invent its own interval, in which case they disagree, or fire eagerly,
57 //! in which case the webview host sends one request per character over the wire.
58 //!
59 //! Note what is *not* being asked for: not a number. "150ms" is a host's
60 //! judgment about its own input latency, the same kind of thing
61 //! `Message::undo`'s header refuses to carry. What is missing is the app's half
62 //! — *this write is expensive, settle before firing it* — which the host then
63 //! answers with an interval of its own choosing. Filed rather than invented.
64 //!
65 //! # What is deliberately not described
66 //!
67 //! - ~~**The Import and Export menus.**~~ Described as of the import flow's
68 //! pass, which is where they said they belonged. Import is an overlay
69 //! ([`importing::open`](super::importing)) because the shipped control is a
70 //! popup of three choices; Export is a single act, because the shipped control
71 //! is a single button. Both are doors into a flow rather than controls of the
72 //! toolbar's own, which is why neither answers a screen here.
73 //! - **The theme selector.** Settings describes it already (`quasi/settings.rs`),
74 //! and a second copy in the toolbar would be the drift this layer exists to
75 //! end. The shipped toolbar has one because a menu was the convenient place
76 //! for it.
77 //! - **A search field as its own kind.** `FieldKind` has `Text`, `Email`, `Url`,
78 //! `Tel` and ten more, and no `Search`. It is described as text, which is
79 //! right about what is typed and loses the affordance a webview and a phone
80 //! keyboard both have for it. One line rather than a finding: the fix is a
81 //! member, and nothing else in this app wants it.
82 //! - **Save-as-collection's popover.** It is described as an overlay, which is
83 //! near enough and not exact: `Outcome::Over` is app-modal, and this is a
84 //! popover anchored to the button that opened it. The difference is where a
85 //! host draws it rather than what it holds, so no finding — but a vocabulary
86 //! that grows anchoring should know this was the first place it mattered.
87
88 use quasi_router::layout::{FieldKind, Priority, Selector, Tone, Width};
89 use quasi_router::{
90 Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
91 Slot,
92 };
93
94 use super::{Panel, Panels, Where};
95
96 /// The band above the list.
97 const BAR: &str = "toolbar-bar";
98
99 /// What a search submits.
100 const QUERY: &str = "query";
101 /// What the save-as-collection form submits.
102 const NAME: &str = "name";
103
104 /// Register the toolbar's routes.
105 ///
106 /// Everything answers the whole main screen, for the sidebar's reason: searching
107 /// and navigating change what the list holds, so the answer is the window.
108 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
109 router
110 .post("/search", search)
111 .post("/search/scope", scope)
112 .post("/search/save", save)
113 .get("/search/save", saving)
114 .post("/undo", undo)
115 .post("/panels/{panel}", toggle)
116 .post("/here/root", root)
117 .post("/here/{id}/{depth}", go)
118 .post("/here/leave", leave)
119 }
120
121 /// `POST /search`
122 fn search(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
123 let query = request.payload.get(QUERY).unwrap_or_default();
124 state.bar.search(query);
125 Ok(super::shell::screen(state).into())
126 }
127
128 /// `POST /search/scope`
129 fn scope(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 let chosen = request.payload.get(Node::SELECTED).unwrap_or_default();
131 match chosen {
132 "all" => state.bar.set_scope(true),
133 "folder" => state.bar.set_scope(false),
134 _ => return Err(RouteError::not_found("no such scope")),
135 }
136 Ok(super::shell::screen(state).into())
137 }
138
139 /// `GET /search/save`
140 ///
141 /// The name is offered already filled in, which is the shipped popup's own
142 /// behaviour: `SearchFilter::describe` writes a sentence out of the active
143 /// filters, so the common case is pressing Save twice.
144 fn saving(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
145 let searching = state.bar.searching();
146 if !searching.filtered {
147 return Err(RouteError::not_found("nothing is filtered"));
148 }
149 Ok(Response::over(
150 Screen::sidebar_content("Save as collection").with(
151 Slot::new("save-collection", RegionKind::Pane)
152 .with(Node::page("Save as collection"))
153 .with(Node::text(
154 "A dynamic collection re-applies these filters, so it updates itself as samples match.",
155 ))
156 .with(Node::Form {
157 fields: vec![
158 Field::new(FieldKind::Text, NAME, "Name")
159 .required()
160 .value(searching.describes)
161 .hint("e.g. Kicks Under 120 BPM"),
162 ],
163 submit: "Save collection".to_owned(),
164 action: Action::post("/search/save"),
165 }),
166 ),
167 ))
168 }
169
170 /// `POST /search/save`
171 fn save(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
172 let name = request.payload.get(NAME).unwrap_or_default().trim();
173 if name.is_empty() {
174 return Err(RouteError::not_found("a collection needs a name"));
175 }
176 state.bar.save_collection(name);
177 Ok(super::shell::screen(state).into())
178 }
179
180 /// `POST /undo`
181 ///
182 /// Refused where there is nothing to undo, which is what the shipped button is
183 /// disabled on.
184 ///
185 /// Not [`Message::undo`](quasi_router::Message), which is the transient offer
186 /// that comes with a toast and expires. This is a standing capability over the
187 /// app's own bulk-operation stack, so it is a control on the screen rather than
188 /// a rider on a notice.
189 fn undo(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
190 if !state.bar.undoable() {
191 return Err(RouteError::not_found("there is nothing to undo"));
192 }
193 state.bar.undo();
194 Ok(super::shell::screen(state).into())
195 }
196
197 /// `POST /panels/{panel}`
198 fn toggle(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
199 let named = request.captures.require("panel")?;
200 let panel = Panel::from_key(named).ok_or_else(|| RouteError::not_found("no such panel"))?;
201 state.bar.toggle(panel);
202 Ok(super::shell::screen(state).into())
203 }
204
205 /// `POST /here/root`
206 fn root(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
207 state.bar.go_root();
208 Ok(super::shell::screen(state).into())
209 }
210
211 /// `POST /here/{id}/{depth}`
212 ///
213 /// The depth rides with the id because navigating to a crumb also truncates the
214 /// trail behind it, and how far along a crumb sits is a fact about *this* trail
215 /// rather than about the folder. A folder reached two ways has one id and two
216 /// depths.
217 fn go(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
218 let id: i64 = request
219 .captures
220 .require("id")?
221 .parse()
222 .map_err(|_| RouteError::not_found("no such folder"))?;
223 let depth: usize = request
224 .captures
225 .require("depth")?
226 .parse()
227 .map_err(|_| RouteError::not_found("no such place in the trail"))?;
228 state.bar.go_to(id, depth);
229 Ok(super::shell::screen(state).into())
230 }
231
232 /// `POST /here/leave`
233 fn leave(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
234 state.bar.leave();
235 Ok(super::shell::screen(state).into())
236 }
237
238 /// The toolbar, as a region something else holds.
239 pub fn body(state: &Panels<'_>) -> Slot {
240 let bar = Slot::new(BAR, RegionKind::Band);
241 let bar = here(bar, state);
242 let bar = looking(bar, state);
243 panels(bar, state)
244 }
245
246 /// Where you are, which is one of three things.
247 ///
248 /// A trail of [`Node::Link`]s rather than acts. Both call a route; the
249 /// difference is what the reader sees, and `Link`'s own header has the argument:
250 /// "making every linked value a button would put a row of bevels down the first
251 /// column of half a dashboard". A breadcrumb is the case that argument was
252 /// written for.
253 fn here(bar: Slot, state: &Panels<'_>) -> Slot {
254 match state.bar.place() {
255 Where::Folder { trail } => {
256 let mut bar = bar.with(Node::Link {
257 text: "/".to_owned(),
258 action: Action::post("/here/root"),
259 });
260 for (depth, crumb) in trail.iter().enumerate() {
261 // The last crumb is where you are, so it goes nowhere. Said as
262 // prose rather than as a link that does nothing, which is the
263 // shipped row's `selectable_label(is_last, ..)` made explicit.
264 if depth + 1 == trail.len() {
265 bar = bar.with(Node::Text {
266 text: crumb.name.clone(),
267 tone: Tone::Info,
268 });
269 } else {
270 bar = bar.with(Node::Link {
271 text: crumb.name.clone(),
272 action: Action::post(format!("/here/{}/{}", crumb.id, depth + 1)),
273 });
274 }
275 }
276 bar
277 }
278 // A mode rather than a place, so the way out is a control and not a
279 // shorter path. The shipped breadcrumb puts Clear in the same segment
280 // as the label for exactly this reason: "the mode label and the exit
281 // affordance occupy one row, not two".
282 Where::Collection { name } => {
283 leaving(bar, format!("Collection: {name}"), "Back to browsing")
284 }
285 Where::Similar { name } => leaving(bar, format!("Similar to: {name}"), "Back to browsing")
286 .with(Node::text(
287 "Results are ranked by similarity, so column sort is off.",
288 )),
289 }
290 }
291
292 /// A mode you are in, and the way out of it.
293 fn leaving(bar: Slot, says: String, out: &str) -> Slot {
294 bar.with(Node::Text {
295 text: says,
296 tone: Tone::Info,
297 })
298 .with(Node::Act(Act::new(out, Action::post("/here/leave"))))
299 }
300
301 /// What you are looking for.
302 fn looking(bar: Slot, state: &Panels<'_>) -> Slot {
303 let searching = state.bar.searching();
304
305 let mut bar = bar
306 .with(Node::Field(Box::new(
307 Field::new(FieldKind::Text, QUERY, "Search")
308 .value(&searching.query)
309 .hint("Search samples...")
310 // What `trailing_width` was measuring for, said instead of
311 // measured. quasi 0.17.0, settled by Max: fill is determined at
312 // the description stage. It is the default, so this line changes
313 // no pixel -- and it is the difference between a row that
314 // happens to look right and one that says what it means.
315 .width(Width::Fill)
316 .changes(Action::post("/search")),
317 )))
318 .with(Node::Select {
319 kind: Selector::Segmented,
320 options: vec![
321 (Choice::new("folder", "This folder"), None),
322 (Choice::new("all", "Everywhere"), None),
323 ],
324 chosen: Some(
325 if searching.everywhere {
326 "all"
327 } else {
328 "folder"
329 }
330 .to_owned(),
331 ),
332 action: Some(Action::post("/search/scope")),
333 });
334
335 if searching.filtered {
336 bar = bar
337 .with(Node::Figure(quasi_router::Figure::new(
338 searching.results.to_string(),
339 "results",
340 )))
341 .with(Node::Act(Act::new(
342 "Save as collection",
343 Action::get("/search/save"),
344 )));
345 }
346
347 let mut undo = Act::new("Undo", Action::post("/undo")).key("ctrl+z");
348 if !state.bar.undoable() {
349 undo = undo.disabled();
350 }
351 bar.with(Node::Act(undo))
352 }
353
354 /// What is showing, and the places the toolbar goes.
355 ///
356 /// The six toggles are latching, which is what a panel that is open or shut is,
357 /// and the last three controls are addresses this router already serves. That is
358 /// the toolbar's own claim: Settings, Cloud Sync and Help are screens, so
359 /// reaching them is navigation rather than something the host arranges.
360 fn panels(bar: Slot, state: &Panels<'_>) -> Slot {
361 use quasi_router::Tag;
362 use quasi_router::layout::Token;
363
364 let showing = state.bar.showing();
365 let mut bar = bar;
366
367 for panel in Panel::ALL {
368 let on = showing.contains(&panel);
369 let mut chip = Tag {
370 kind: Token::Chip { removable: false },
371 label: panel.label().to_owned(),
372 tone: if on { Tone::Info } else { Tone::Neutral },
373 latched: on,
374 action: Some(Action::post(format!("/panels/{}", panel.as_str()))),
375 };
376 // How many filters are on, on the chip that toggles the filter panel.
377 // The shipped toggle takes an `Option<usize>` badge for this and nothing
378 // else does, so it is the one toggle carrying a count.
379 if panel == Panel::Filters && state.bar.searching().filters > 0 {
380 chip.label = format!("{} ({})", chip.label, state.bar.searching().filters);
381 }
382 bar = bar.with_ranked(Node::Token(chip), panel.worth());
383 }
384
385 // Import holds at Essential, alone among the right-hand controls, and it is
386 // the shipped bar's own judgment: it bolds the label when the library is
387 // empty because it is the one action that does anything then. A control that
388 // is the only way to have any content is not one a narrow window drops.
389 let bar = bar
390 .with(Node::Act(Act::new("Import", Action::get("/import/open"))))
391 .with_ranked(
392 Node::Act(Act::new("Export", Action::post("/export/begin"))),
393 Priority::Secondary,
394 );
395
396 // Settings and Cloud Sync are how you reach two whole screens and have no
397 // other route in, so they hold at Secondary. Help drops first because it
398 // is the one control here that keeps working when it is not on the screen:
399 // it carries `f1`, and a key is a route a narrow window cannot take away.
400 bar.with_ranked(
401 Node::Act(Act::new("Settings", Action::get("/settings"))),
402 Priority::Secondary,
403 )
404 .with_ranked(
405 Node::Act(Act::new("Cloud Sync", Action::get("/sync"))),
406 Priority::Secondary,
407 )
408 .with_ranked(
409 Node::Act(Act::new("Help", Action::get("/help")).key("f1")),
410 Priority::Optional,
411 )
412 }
413