Skip to main content

max / audiofiles

Describe the three bulk modals Seven shapes: the tag, move and rename screens, the destination row, the preview, the chosen list and the cancel. Two helpers had to move first. `closing` took the modal's region and handed back a screen, and `subjects` took the region and handed it back, so between them every modal was unconvertible. The cancel is a shape of its own now, included by each screen, and the chosen list answers with nodes the region splices. Each modal writes its own screen, which is what three descriptions of three modals should read as. The preview was a fallible read behind three early returns; hoisted, it is one dispatch over what there is to say. The collision cell is `cell "" { badge .. }`, which builds the same value `Cell::tag` does and is what that constructor's own doc says.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:18 UTC
Signed with PGP, not checked
Commit: 5832e4bb396d00e9a72eee2f60c29a2ea0e37ee0
Parent: 1ec0021
2 files changed, +227 insertions, -224 deletions
M Cargo.lock +12 -12
@@ -7566,18 +7566,6 @@
7566 7566 "winnow 1.0.4",
7567 7567 ]
7568 7568
7569 - [[patch.unused]]
7570 - name = "kberg"
7571 - version = "0.1.0"
7572 -
7573 - [[patch.unused]]
7574 - name = "ops-status"
7575 - version = "0.1.0"
7576 -
7577 - [[patch.unused]]
7578 - name = "painhours"
7579 - version = "0.1.0"
7580 -
7581 7569 [[patch.unused]]
7582 7570 name = "quasi-axum"
7583 7571 version = "0.101.1"
@@ -7606,6 +7594,18 @@
7606 7594 name = "quasi-webview"
7607 7595 version = "0.101.1"
7608 7596
7597 + [[patch.unused]]
7598 + name = "kberg"
7599 + version = "0.1.0"
7600 +
7601 + [[patch.unused]]
7602 + name = "ops-status"
7603 + version = "0.1.0"
7604 +
7605 + [[patch.unused]]
7606 + name = "painhours"
7607 + version = "0.1.0"
7608 +
7609 7609 [[patch.unused]]
7610 7610 name = "quasi-type"
7611 7611 version = "0.1.3"
@@ -63,10 +63,10 @@
63 63 //! Three consumers, three members, one gap. Said as prose here for the same
64 64 //! reason and with the same complaint.
65 65
66 - use quasi_router::layout::{FieldKind, Tone};
66 + use quasi_declare::declare;
67 + use quasi_router::layout::Tone;
67 68 use quasi_router::{
68 - Act, Action, Cell, Cells, Choice, Column, Consult, Field, Node, Outcome, RegionKind, Request,
69 - Response, Rest, RouteError, Router, Screen, Slot, Table, Tag,
69 + Action, Choice, Consult, Outcome, Request, Response, Rest, RouteError, Router, Screen,
70 70 };
71 71
72 72 use super::{Chosen, Panels};
@@ -157,7 +157,7 @@
157 157 if chosen.samples == 0 {
158 158 return Err(RouteError::not_found("no samples are chosen"));
159 159 }
160 - Ok(over(tagging(state, &chosen, None, true)))
160 + Ok(over(tagging(&tagging_read(state, &chosen, None, true))))
161 161 }
162 162
163 163 /// `POST /bulk/tag`
@@ -171,8 +171,10 @@
171 171 let chosen = state.bulk.chosen();
172 172
173 173 if typed.is_empty() {
174 - return Ok(over(tagging(state, &chosen, Some(""), adding))
175 - .toast(Tone::Danger, "Type a tag first."));
174 + return Ok(
175 + over(tagging(&tagging_read(state, &chosen, Some(""), adding)))
176 + .toast(Tone::Danger, "Type a tag first."),
177 + );
176 178 }
177 179 // The same refusal the shipped Apply button makes, made by the route as
178 180 // well: an address is reachable by typing, so a disabled control is an
@@ -196,69 +198,13 @@
196 198 /// What the shipped Apply button says when it will not run.
197 199 const UNKNOWN: &str = "None of the selected samples have this tag.";
198 200
199 - /// The tag modal.
200 - fn tagging(state: &Panels<'_>, chosen: &Chosen, typed: Option<&str>, adding: bool) -> Screen {
201 - let mut body = Slot::new(BODY, RegionKind::Pane)
202 - .with(Node::page(format!("Tag {} samples", chosen.samples)));
203 -
204 - // Add or remove, as one control rather than two selectable labels. The
205 - // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a
206 - // segmented control spelled out.
207 - //
208 - // A field in the form rather than a control beside it, which is what it
209 - // always was: nothing writes when it changes, and `tag` reads it out of the
210 - // payload at submit alongside the tag itself.
211 - let mode = Field::radio(
212 - MODE,
213 - "Mode",
214 - vec![
215 - Choice::new("add", "Add tag"),
216 - Choice::new("remove", "Remove tag"),
217 - ],
218 - )
219 - .value(if adding { "add" } else { "remove" });
220 -
221 - let mut field = Field::new(FieldKind::Text, TAG, "Tag").hint("e.g. genre.electronic");
222 - if let Some(typed) = typed {
223 - field = field.value(typed);
224 - }
225 - body = body
226 - .with(Node::Form {
227 - fields: vec![mode, field],
228 - submit: "Apply".to_owned(),
229 - action: Action::post("/bulk/tag"),
230 - })
231 - .with(Node::text(if adding {
232 - "Will add to every selected sample that lacks it."
233 - } else {
234 - "Will remove from selected samples that have this tag."
235 - }));
236 -
237 - // Every tag the vault knows, as badges under the field. The shipped modal
238 - // filters this set to a substring of what is typed, caps it at twelve, and
239 - // fills the field when one is clicked -- so this is the **second consumer**
240 - // of `quasi:vocabulary:text-into-field`, the finding the export port filed
241 - // for the naming-pattern chips. A `Field` cannot say what completes it, so
242 - // the set is named and the reader types.
243 - //
244 - // The narrowing is not described and should not be: what the app knows is
245 - // the whole set, and how many of them a host shows while someone types is
246 - // the host's business.
247 - body = body.with(Node::section("Known tags"));
248 - for known in state.bulk.known_tags().iter().take(SHOWN) {
249 - body = body.with(Node::Token(Tag::badge(known.clone())));
250 - }
251 -
252 - closing(subjects(body, &chosen.names))
253 - }
254 -
255 201 /// `GET /bulk/move`
256 202 fn move_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
257 203 let chosen = state.bulk.chosen();
258 204 if chosen.names.is_empty() {
259 205 return Err(RouteError::not_found("nothing is chosen"));
260 206 }
261 - Ok(over(moving(state, &chosen)))
207 + Ok(over(moving(&moving_read(state, &chosen))))
262 208 }
263 209
264 210 /// `POST /bulk/move`
@@ -287,53 +233,13 @@
287 233 Ok(Response::from(leaving()).toast(Tone::Success, format!("Moving {count} items.")))
288 234 }
289 235
290 - /// The move modal.
291 - ///
292 - /// A table of one column rather than a list, because picking a row is what this
293 - /// screen is for and `Cells::activate` is what says a row is pressable. The
294 - /// shipped modal draws `selectable_label` per directory with a substring filter
295 - /// above it; the filter is not described, on the rule the tag completions
296 - /// follow — narrowing a list while someone types is what a host does with a list
297 - /// it was handed.
298 - fn moving(state: &Panels<'_>, chosen: &Chosen) -> Screen {
299 - let folders = state.bulk.folders();
300 - let mut rows = vec![row("/", ROOT)];
301 - rows.extend(
302 - folders
303 - .iter()
304 - .map(|folder| row(&folder.path, &folder.id.to_string())),
305 - );
306 -
307 - let body = Slot::new(BODY, RegionKind::Pane)
308 - .with(Node::page(format!("Move {} items", chosen.names.len())))
309 - .with(Node::text("Choose where they go."))
310 - // No `more`: every folder in the vault is here, because a destination
311 - // the picker does not show is a destination you cannot choose.
312 - .with(Node::from(
313 - Table::new(vec![Column::new(DESTINATION)]).rows(rows),
314 - ));
315 -
316 - closing(subjects(body, &chosen.names))
317 - }
318 -
319 - /// One destination, as a row that submits itself.
320 - ///
321 - /// The cell names [`DESTINATION`] rather than counting to it: the column list is
322 - /// in [`moving`] and this is not, so position here would be an agreement between
323 - /// two functions that nothing enforces.
324 - fn row(path: &str, value: &str) -> Cells {
325 - Cells::default()
326 - .at(DESTINATION, Cell::new(path))
327 - .activate(Action::post("/bulk/move").carrying(FOLDER, value))
328 - }
329 -
330 236 /// `GET /bulk/rename`
331 237 fn rename_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
332 238 let chosen = state.bulk.chosen();
333 239 if chosen.names.is_empty() {
334 240 return Err(RouteError::not_found("nothing is chosen"));
335 241 }
336 - Ok(over(renaming(state, START)))
242 + Ok(over(renaming(&renaming_read(state, START))))
337 243 }
338 244
339 245 /// `POST /bulk/rename/preview`
@@ -350,7 +256,7 @@
350 256 let pattern = request.payload.get(PATTERN).unwrap_or_default();
351 257 Ok(Response::from(Outcome::Fragment {
352 258 region: PREVIEW.to_owned(),
353 - node: previewed(state, pattern),
259 + node: previewed(&preview_read(state, pattern)),
354 260 }))
355 261 }
356 262
@@ -389,134 +295,419 @@
389 295 /// The region the preview lands in.
390 296 const PREVIEW: &str = "bulk-rename-preview";
391 297
392 - /// The rename modal.
393 - fn renaming(state: &Panels<'_>, pattern: &str) -> Screen {
394 - let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Rename pattern"));
298 + /// What a tag modal draws, read off the app.
299 + struct Tagging {
300 + /// The heading.
301 + title: String,
302 + /// Which of the two modes is chosen.
303 + mode: &'static str,
304 + /// What is in the tag box, where anything is.
305 + typed: Option<String>,
306 + /// What the apply will do, said under the form.
307 + will: &'static str,
308 + /// Every tag the vault knows, as far as the modal shows them.
309 + ///
310 + /// The shipped modal filters this set to a substring of what is typed, caps
311 + /// it at twelve, and fills the field when one is clicked -- so this is the
312 + /// **second consumer** of `quasi:vocabulary:text-into-field`, the finding
313 + /// the export port filed for the naming-pattern chips. A `Field` cannot say
314 + /// what completes it, so the set is named and the reader types.
315 + ///
316 + /// The narrowing is not described and should not be: what the app knows is
317 + /// the whole set, and how many of them a host shows while someone types is
318 + /// the host's business.
319 + known: Vec<String>,
320 + /// Every name the operation touches.
321 + chosen: Names,
322 + }
395 323
396 - // The tokens, as controls that put themselves in the box.
397 - //
398 - // Badges until 2026-08-22, with a comment naming the gap they were standing
399 - // in for: `quasi:vocabulary:text-into-field`, filed by the export port for
400 - // the same control. Max ruled it (`f35aafee`) and `Act::fills` landed in
401 - // quasi 0.55.0, so the workaround is over and the flip does not have to
402 - // lose the shipped palette. `Action::local` is the "no request goes out"
403 - // half: pressing one of these writes into the box and calls nothing.
404 - //
405 - // The renderer appends rather than replaces, which is what the shipped
406 - // modal's `pattern_input.push_str(token)` does, so `{name}_{bpm}` is still
407 - // built by pressing two of them.
408 - for token in TOKENS {
409 - body = body.with(Node::Act(
410 - Act::new(token, Action::local()).filling(PATTERN, token),
411 - ));
324 + /// What a move modal draws, read off the app.
325 + struct Moving {
326 + /// The heading.
327 + title: String,
328 + /// Every folder in the vault, and the root.
329 + ///
330 + /// No `more`: a destination the picker does not show is a destination you
331 + /// cannot choose.
332 + folders: Vec<Destination>,
333 + /// Every name the operation touches.
334 + chosen: Names,
335 + }
336 +
337 + /// One destination a move may target.
338 + struct Destination {
339 + /// What the row reads.
340 + path: String,
341 + /// What choosing it submits.
342 + value: String,
343 + }
344 +
345 + /// What a rename modal draws, read off the app.
346 + struct Renaming {
347 + /// What is in the pattern box.
348 + pattern: String,
349 + /// What that pattern would do.
350 + preview: Preview,
351 + }
352 +
353 + /// What a pattern would do, old name beside new.
354 + struct Preview {
355 + /// Which of the three things there is to say.
356 + shown: Shown,
357 + /// Why the pattern is not a pattern yet.
358 + ///
359 + /// Empty unless [`shown`](Self::shown) is [`Shown::Failed`], which is the
360 + /// pair the rule keeps: this is a three-way question rather than "is it
361 + /// there", and the answer feeds one member of one arm.
362 + why: String,
363 + /// The rows, as far as the modal shows them.
364 + lines: Vec<Renamed>,
365 + /// How many there would be in all.
366 + total: usize,
367 + /// Whether that is more than are drawn.
368 + capped: bool,
369 + }
370 +
371 + /// What there is to say about a pattern.
372 + enum Shown {
373 + /// The pattern is being typed, so half of it is not a pattern yet. Said
374 + /// rather than drawn as an empty table, which would read as "this renames
375 + /// nothing".
376 + Failed,
377 + /// It parses and renames nothing.
378 + Empty,
379 + /// It parses and renames these.
380 + Listed,
381 + }
382 +
383 + /// One rename, old name beside new.
384 + struct Renamed {
385 + /// What the file is called now.
386 + old: String,
387 + /// What it would be called.
388 + new: String,
389 + /// Whether something else would be called that too.
390 + ///
391 + /// Counted once over the whole set rather than per row, which is the
392 + /// shipped modal's own reasoning ("counting once is the whole point, doing
393 + /// it per-row would be O(n^2)") and holds here for the same reason.
394 + collides: bool,
395 + }
396 +
397 + /// Every name an operation touches.
398 + ///
399 + /// The shipped modals each scroll this list at a fixed height; how much of it
400 + /// fits is the host's, and how many there are is the description's. [`Rest`] is
401 + /// what says the second part, so the overflow is a described fact rather than a
402 + /// sentence at the end of the list.
403 + struct Names {
404 + /// The heading, which is the count.
405 + count: String,
406 + /// The names, as far as the modal shows them.
407 + shown: Vec<String>,
408 + /// How many there are in all.
409 + total: usize,
410 + /// Whether that is more than are drawn.
411 + capped: bool,
412 + }
413 +
414 + /// What a tag modal draws, read off the app.
415 + fn tagging_read(state: &Panels<'_>, chosen: &Chosen, typed: Option<&str>, adding: bool) -> Tagging {
416 + Tagging {
417 + title: format!("Tag {} samples", chosen.samples),
418 + mode: if adding { "add" } else { "remove" },
419 + typed: typed.map(str::to_owned),
420 + will: if adding {
421 + "Will add to every selected sample that lacks it."
422 + } else {
423 + "Will remove from selected samples that have this tag."
424 + },
425 + known: state.bulk.known_tags().into_iter().take(SHOWN).collect(),
426 + chosen: names(&chosen.names),
412 427 }
413 -
414 - body = body.with(Node::Form {
415 - fields: vec![
416 - Field::new(FieldKind::Text, PATTERN, "Pattern")
417 - .value(pattern)
418 - .hint("{name}_{bpm}")
419 - // The preview is an answer about the pattern, not a rename:
420 - // `/bulk/rename` is the form's own action and is the only
421 - // thing here that moves a file.
422 - .consulting(Consult::new(Action::post("/bulk/rename/preview"))),
423 - ],
424 - submit: "Rename".to_owned(),
425 - action: Action::post("/bulk/rename"),
426 - });
427 -
428 - closing(body.with(Node::Region(
429 - Slot::new(PREVIEW, RegionKind::Group).with(previewed(state, pattern)),
430 - )))
431 428 }
432 429
433 - /// The modal, with the one control a description cannot honestly say.
434 - ///
435 - /// See finding 1. `Act::key` names Escape because Escape is what actually
436 - /// dismisses an overlay, and the address is what a host with no Escape key has
437 - /// instead.
438 - fn closing(body: Slot) -> Screen {
439 - Screen::sidebar_content("Bulk")
440 - .with(body.with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc"))))
430 + /// What a move modal draws, read off the app.
431 + fn moving_read(state: &Panels<'_>, chosen: &Chosen) -> Moving {
432 + let mut folders = vec![Destination {
433 + path: "/".to_owned(),
434 + value: ROOT.to_owned(),
435 + }];
436 + folders.extend(state.bulk.folders().iter().map(|folder| Destination {
437 + path: folder.path.clone(),
438 + value: folder.id.to_string(),
439 + }));
440 + Moving {
441 + title: format!("Move {} items", chosen.names.len()),
442 + folders,
443 + chosen: names(&chosen.names),
444 + }
441 445 }
442 446
443 - /// What the pattern would do, old name beside new.
444 - ///
445 - /// Its own node so the preview route can answer it as a fragment, which is what
446 - /// keeps the overlay standing while it updates.
447 - fn previewed(state: &Panels<'_>, pattern: &str) -> Node {
447 + /// What a rename modal draws, read off the app.
448 + fn renaming_read(state: &Panels<'_>, pattern: &str) -> Renaming {
449 + Renaming {
450 + pattern: pattern.to_owned(),
451 + preview: preview_read(state, pattern),
452 + }
453 + }
454 +
455 + /// What a pattern would do, read off the app.
456 + fn preview_read(state: &Panels<'_>, pattern: &str) -> Preview {
448 457 let previews = match state.bulk.previews(pattern) {
449 458 Ok(previews) => previews,
450 - // The pattern is being typed, so half of it is not a pattern yet. Said
451 - // rather than drawn as an empty table, which would read as "this renames
452 - // nothing".
453 459 Err(why) => {
454 - return Node::banner(Tone::Danger, why);
460 + return Preview {
461 + shown: Shown::Failed,
462 + why,
463 + lines: Vec::new(),
464 + total: 0,
465 + capped: false,
466 + };
455 467 }
456 468 };
457 - if previews.is_empty() {
458 - return Node::empty("Nothing to rename.");
459 - }
460 469
461 - // Collisions counted once over the whole set rather than per row, which is
462 - // the shipped modal's own reasoning ("counting once is the whole point,
463 - // doing it per-row would be O(n^2)") and holds here for the same reason.
464 470 let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
465 471 for (_, new) in &previews {
466 472 *seen.entry(new.as_str()).or_insert(0) += 1;
467 473 }
468 474
469 475 let total = previews.len();
470 - // Two columns and two cells, written here in one expression, and every row
471 - // has both. That is the case position is still safe in, so the rows stay
472 - // positional: there is no second function for a heading to drift in, and no
473 - // branch that drops a cell -- the collision fork changes what the New cell
474 - // holds, never whether it is there.
475 - let table = Table::new(vec![Column::new("Old"), Column::new("New")]).rows(
476 - previews.iter().take(SHOWN).map(|(old, new)| {
477 - let collides = seen.get(new.as_str()).copied().unwrap_or(0) > 1;
478 - Cells::new(vec![
479 - Cell::new(old),
480 - // A collision is a tone on the value rather than a hover on it,
481 - // for the reason a suggestion's score is in its label in
482 - // `detail`: a reader with no pointer never sees a hover, and
483 - // this one is a warning about losing files.
484 - if collides {
485 - Cell::tag(Tag::badge(new.clone()).tone(Tone::Warning))
486 - } else {
487 - Cell::new(new)
488 - },
489 - ])
490 - }),
491 - );
492 - // Said rather than implied, as of quasi 0.15: a described table of the first
493 - // fifty of five hundred was indistinguishable from a table of fifty until a
494 - // table grew `more`, which is the gap this port noted at 65abb3c. No
495 - // `forward`, because there is nowhere to ask -- the cap is a rendering
496 - // budget and the rename acts on all of them.
497 - if total > SHOWN {
498 - table.more(Rest::showing(SHOWN).of(total)).into()
499 - } else {
500 - table.into()
476 + Preview {
477 + shown: if total == 0 {
478 + Shown::Empty
479 + } else {
480 + Shown::Listed
481 + },
482 + why: String::new(),
483 + lines: previews
484 + .iter()
485 + .take(SHOWN)
486 + .map(|(old, new)| Renamed {
487 + old: old.clone(),
488 + new: new.clone(),
489 + collides: seen.get(new.as_str()).copied().unwrap_or(0) > 1,
490 + })
491 + .collect(),
492 + total,
493 + capped: total > SHOWN,
501 494 }
502 495 }
503 496
504 - /// Every name the operation touches.
505 - ///
506 - /// The shipped modals each scroll this list at a fixed height; how much of it
507 - /// fits is the host's, and how many there are is the description's. `Rest` is
508 - /// what says the second part, so the overflow is a described fact rather than a
509 - /// sentence at the end of the list.
510 - fn subjects(body: Slot, names: &[String]) -> Slot {
511 - body.with(Node::section(format!("{} chosen", names.len())))
512 - .with(Node::List {
513 - rows: names
514 - .iter()
515 - .take(SHOWN)
516 - .map(quasi_router::Row::new)
517 - .collect(),
518 - more: (names.len() > SHOWN).then(|| Rest::showing(SHOWN).of(names.len())),
519 - })
497 + /// Every name an operation touches, read off what was chosen.
498 + fn names(chosen: &[String]) -> Names {
499 + Names {
500 + count: format!("{} chosen", chosen.len()),
501 + shown: chosen.iter().take(SHOWN).cloned().collect(),
502 + total: chosen.len(),
503 + capped: chosen.len() > SHOWN,
504 + }
505 + }
506 +
507 + declare! {
508 + /// The tag modal.
509 + shape tagging(tagging: &Tagging) -> Screen;
510 +
511 + screen sidebar_content "Bulk" {
512 + region BODY as Pane {
513 + page &tagging.title;
514 +
515 + form post "/bulk/tag" {
516 + submit "Apply";
517 +
518 + // Add or remove, as one control rather than two selectable
519 + // labels. The shipped modal draws
520 + // `selectable_value(adding, true, ..)` twice, which is a
521 + // segmented control spelled out.
522 + //
Lines truncated