Skip to main content

max / audiofiles

25.3 KB · 628 lines History Blame Raw
1 //! The file list, described rather than built.
2 //!
3 //! The app's main screen and the third port. It is the one that needed
4 //! `quasi-immediate` to grow a table first, and the one where the port is
5 //! *least* of a rewrite: `ui::file_list::describe` already builds
6 //! `makeover_layout::Column`s and hands them to `makeover_immediate::table`, so
7 //! the columns were described before this module existed. What was not described
8 //! is everything below the header — the rows, and what pressing one calls.
9 //!
10 //! # What is different about this screen, and it is not the table
11 //!
12 //! Every port before this one wrote through a handle that already took `&self`:
13 //! the config store, the sync manager. **The file list acts on the app's own
14 //! in-memory UI state** — which row is selected, what is playing — and those are
15 //! `&mut BrowserState`.
16 //!
17 //! A handler is `fn(&S, Request)`. So a described file list cannot select a row
18 //! by reaching for the field, and the answer is not to make the state
19 //! interior-mutable to suit a description.
20 //!
21 //! The answer is the app's own, already in the codebase: `SettingsUiState` has
22 //! `pending_action: Option<VaultAction>`, documented as "set by the UI, consumed
23 //! by the app layer each frame". [`Intents`] is that pattern for described
24 //! screens. The route records what the user asked for, the panel applies it to
25 //! `&mut BrowserState` after the frame is drawn, and nothing needs a lock.
26 //!
27 //! Worth stating as a rule for the next port that meets this: **a described
28 //! screen writing to UI state records an intent; a described screen writing to
29 //! the app's data calls through a handle.** The first is not a lesser kind of
30 //! port, it is what a frame boundary looks like from the description's side.
31 //!
32 //! # What is deliberately not described
33 //!
34 //! - **Drag and drop out of the app.** `draw_file_list` carries a macOS/Windows
35 //! drag-cooldown state machine so an OS drag that ends outside the window does
36 //! not leave egui's pointer state stale. That is a host input problem and
37 //! nothing about it is a fact about a sample.
38 //! - **The waveform and the inline rename.** Each is its own affordance;
39 //! folding them in here would make the port about size rather than about
40 //! shape.
41 //!
42 //! # The context menus, measured 2026-08-17, and they split three ways
43 //!
44 //! `ui/file_list_menus.rs` is 545 lines of three menus — one for the row under
45 //! the cursor (14 entries), one for the selection (11), one for empty space (5)
46 //! — and the question asked of them was whether a context menu can be described
47 //! at all. It can, partly, and where it cannot is two members rather than one
48 //! vague gap.
49 //!
50 //! **`Row::menu` is exactly the first one, on the wrong container.** Its own
51 //! header says what it is for — "what it *offers*, reached by right-click on a
52 //! pointer host, long-press on a touch one, and a key in a terminal" — and why
53 //! that belongs to the description rather than to a renderer: one description
54 //! has to become a context menu, an action sheet and a key-driven menu, and no
55 //! single renderer can be where that is said. It is right, and the file list is
56 //! a [`Node::Table`], whose [`Cells`] had no `menu`.
57 //!
58 //! **[`Cells::menu`] exists as of quasi-router 0.20.0, and this screen describes
59 //! its row menu through it.** See `menu` below. The asymmetry had been corrected once
60 //! before in this exact place: `Cells::selected` says it was deliberately absent
61 //! "on the grounds that no table asked for one and a member added because its
62 //! sibling has it is a member with no consumer to tell us what it should mean",
63 //! and that it "was correct until 2026-08-15". This was the same sentence about
64 //! the next field along, and the consumer that ended it is here.
65 //!
66 //! Two things came out of describing it that were not about the member. The
67 //! description needs to know whether a row is a folder and whether its bytes are
68 //! only in the cloud — `draw_context_menu` branches on both and [`Sample`] said
69 //! neither, so every row was getting the sample columns and a Play control,
70 //! folders included. And `quasi-immediate` drew no menu at all, for `Row` either:
71 //! the detail pane's tag rows have offered one since they were described and it
72 //! has never opened, because that renderer had no arm for the member. Both fixed
73 //! in the same pass.
74 //!
75 //! The one entry still not described is **Add to Collection**, the only nested
76 //! one. `Cells::menu` is flat, matching `Row::menu`, and a submenu wants a second
77 //! consumer before [`Act`] grows a child list. Flattening it reads "Add to
78 //! Kicks", "Add to Breaks" for as many collections as exist, which is honest and
79 //! gets long.
80 //!
81 //! **The other two have no container at all.** A menu over the *selection* and a
82 //! menu over the *surface* are not per-row, and nothing in the vocabulary holds
83 //! acts back until a host asks for them except `Row::menu`. Described as
84 //! [`Outcome::Over`](quasi_router::Outcome::Over) they become app-modal
85 //! overlays, which is what [`toolbar`](super::toolbar) already recorded of the
86 //! save-as-collection popover — "near enough and not exact" — and what
87 //! [`importing`](super::importing) took for the Import menu as its second
88 //! consumer. These are the third and fourth, and they are the ones that make the
89 //! shape clear: an anchored menu is not a modal, and its subject is whatever it
90 //! opened over. Filed as `quasi:vocabulary:anchored-menu`.
91 //!
92 //! Two of the eleven selection entries and three of the five background entries
93 //! are described already, at addresses of their own —
94 //! [`bulk`](super::bulk)'s three modals, [`naming`](super::naming)'s New Folder,
95 //! [`importing`](super::importing)'s two doors. So what is missing is never the
96 //! contents. It is the gesture and the anchor, both times.
97 //! - **Virtual scrolling.** Recorded in the findings note as renderer policy
98 //! from the start: windowing rows the app already holds is a performance
99 //! technique, not a described fact.
100
101 use quasi_router::layout::{Priority, Sort, Tone, Width};
102 use quasi_router::{
103 Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response,
104 RouteError, Router, Screen, Slot, Tag,
105 };
106
107 use super::{Collection, Panels, Sample};
108
109 /// The region the screen answers into.
110 const BODY: &str = "files-body";
111
112 /// The columns, by the name the sort routes know them by.
113 const NAME: &str = "Name";
114 const DUR: &str = "Duration";
115 const BPM: &str = "BPM";
116 const KEY: &str = "Key";
117 const PEAK: &str = "Peak dB";
118 const TAGS: &str = "Tags";
119 const PLAY: &str = "Play";
120
121 /// What the Add to Collection act asks for, and what its handler reads back.
122 const COLLECTION: &str = "collection";
123
124 /// Register this screen's routes.
125 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
126 router
127 .get("/files", index)
128 .post("/files/{id}/open", open)
129 .post("/files/{id}/play", play)
130 .post("/files/sort/{column}", sort)
131 // The row menu. Five of these hold no capability of their own: they
132 // select the row and then call the handle that already does the act for
133 // the sample in focus, which is why `Files` grew seven methods for
134 // thirteen entries. See `Files`'s own note.
135 .post("/files/{id}/enter", enter)
136 .post("/files/{id}/path/copy", copy_path)
137 .post("/files/{id}/reveal", reveal)
138 .post("/files/{id}/similar", find_similar)
139 .post("/files/{id}/duplicates", find_duplicates)
140 .post("/files/{id}/edit", edit)
141 .post("/files/{id}/instrument", instrument)
142 .post("/files/{id}/export", export)
143 .post("/files/{id}/reanalyze", reanalyze)
144 .post("/files/{id}/delete", delete)
145 .post("/files/{id}/download", download)
146 .post("/files/{id}/collection/remove", remove_from_collection)
147 .post("/files/{id}/collection/add", add_to_collection)
148 }
149
150 /// `GET /files`
151 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
152 Ok(screen(state).into())
153 }
154
155 /// `POST /files/{id}/open`
156 fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
157 let id = id_of(&request)?;
158 state.files.open(id);
159 Ok(screen(state).into())
160 }
161
162 /// `POST /files/{id}/play`
163 fn play(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
164 let id = id_of(&request)?;
165 state.files.play(id);
166 Ok(screen(state).into())
167 }
168
169 /// `POST /files/{id}/enter`
170 ///
171 /// A folder row's Open, which is not [`open`]'s Open: selecting a folder and
172 /// going into it are two acts, and the shipped menu offers the second.
173 fn enter(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
174 let id = id_of(&request)?;
175 state.files.enter(id);
176 Ok(screen(state).into())
177 }
178
179 /// `POST /files/{id}/path/copy`
180 ///
181 /// The first of the five that borrow a capability rather than growing one:
182 /// select the row, then call what the detail pane already calls on the sample in
183 /// focus. The clipboard is `Detail`'s because the detail pane needed it first,
184 /// and a second way to copy a path would be a second way for it to be wrong.
185 fn copy_path(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
186 let id = id_of(&request)?;
187 state.files.open(id);
188 state.detail.copy_path();
189 Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
190 }
191
192 /// `POST /files/{id}/reveal`
193 fn reveal(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
194 let id = id_of(&request)?;
195 state.files.reveal(id);
196 Ok(screen(state).into())
197 }
198
199 /// `POST /files/{id}/similar`
200 fn find_similar(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
201 let id = id_of(&request)?;
202 state.files.open(id);
203 state.detail.find_similar();
204 Ok(screen(state).into())
205 }
206
207 /// `POST /files/{id}/duplicates`
208 fn find_duplicates(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
209 let id = id_of(&request)?;
210 state.files.open(id);
211 state.detail.find_duplicates();
212 Ok(screen(state).into())
213 }
214
215 /// `POST /files/{id}/edit`
216 fn edit(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
217 let id = id_of(&request)?;
218 state.files.open(id);
219 state.detail.edit();
220 Ok(screen(state).into())
221 }
222
223 /// `POST /files/{id}/instrument`
224 fn instrument(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
225 let id = id_of(&request)?;
226 state.files.as_instrument(id);
227 Ok(screen(state).into())
228 }
229
230 /// `POST /files/{id}/export`
231 ///
232 /// The flow opens on whatever is chosen, so this selects the row and then asks
233 /// for it. Same two steps the shipped menu takes, and the answer is the same
234 /// `Goto` [`export`](super::export)'s own `begin` gives, because the flow is a
235 /// screen rather than a change to this one.
236 fn export(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
237 let id = id_of(&request)?;
238 state.files.open(id);
239 state.export.open();
240 Ok(Response::from(quasi_router::Outcome::Goto(Action::get(
241 "/export",
242 ))))
243 }
244
245 /// `POST /files/{id}/reanalyze`
246 fn reanalyze(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
247 let id = id_of(&request)?;
248 state.files.reanalyze(id);
249 Ok(screen(state).into())
250 }
251
252 /// `POST /files/{id}/delete`
253 ///
254 /// The act carries the question, so arriving here means it was answered. The app
255 /// raises its own counted dialog after this, which is the shipped behaviour and
256 /// is not a duplicate of the same question: one asks whether to delete this row
257 /// and the other says how much is about to go.
258 fn delete(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
259 let id = id_of(&request)?;
260 state.files.delete(id);
261 Ok(screen(state).into())
262 }
263
264 /// `POST /files/{id}/download`
265 fn download(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
266 let id = id_of(&request)?;
267 state.files.download(id);
268 Ok(screen(state).into())
269 }
270
271 /// `POST /files/{id}/collection/remove`
272 fn remove_from_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
273 let id = id_of(&request)?;
274 state.files.remove_from_collection(id);
275 Ok(screen(state).into())
276 }
277
278 /// `POST /files/{id}/collection/add`
279 ///
280 /// The collection arrives in the payload because the act asked for it, so this
281 /// reads a submitted value the same way a form's handler does. An id that names
282 /// no collection is a not-found rather than a silent no-op: the list the act
283 /// offered was built from `Library::collections`, so a value outside it means
284 /// the collection went away between the menu opening and the press.
285 fn add_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
286 let id = id_of(&request)?;
287 let chosen = request
288 .payload
289 .get(COLLECTION)
290 .and_then(|value| value.parse::<i64>().ok())
291 .ok_or_else(|| RouteError::not_found("no collection named"))?;
292 let named = state
293 .library
294 .collections()
295 .into_iter()
296 .find(|collection| collection.id == chosen)
297 .ok_or_else(|| RouteError::not_found("no such collection"))?;
298 state.files.add_to_collection(id, named.id);
299 Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
300 }
301
302 /// `POST /files/sort/{column}`
303 ///
304 /// The heading a user pressed. Which way it then sorts is the app's: pressing
305 /// the column already in force reverses it, and the description says only which
306 /// column was named.
307 fn sort(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
308 let column = request.captures.require("column")?;
309 if !sortable(column) {
310 return Err(RouteError::not_found("no such sort"));
311 }
312 state.files.sort_by(column);
313 Ok(screen(state).into())
314 }
315
316 /// The row a request names.
317 fn id_of(request: &Request) -> Result<i64, RouteError> {
318 request
319 .captures
320 .require("id")?
321 .parse()
322 .map_err(|_| RouteError::not_found("no such sample"))
323 }
324
325 /// Whether a column can be ordered by.
326 ///
327 /// Peak and Tags have no sort of their own and never had one, which is what
328 /// `Column::sortable` says when it is false: they are headings rather than
329 /// controls.
330 fn sortable(column: &str) -> bool {
331 matches!(column, NAME | DUR | BPM | KEY)
332 }
333
334 /// The whole screen.
335 fn screen(state: &Panels<'_>) -> Screen {
336 Screen::sidebar_content("Samples").with(body(state))
337 }
338
339 /// The list, as a region something else can hold.
340 ///
341 /// Public because [`shell`](super::shell) puts it inside the main screen rather
342 /// than beside it: the file list is the app's central pane, so a described app
343 /// composes this region while the standalone `/files` window answers it alone.
344 /// Two callers, one description, which is what a region is for.
345 pub fn body(state: &Panels<'_>) -> Slot {
346 let shown = state.files.columns();
347 let samples = state.files.samples();
348 let current = state.files.current();
349 // The collections, which decide two menu entries: which one to take a sample
350 // out of, and the list to offer for putting one in. Read once for the table
351 // rather than per row -- it is a fact about the screen, and every row would
352 // otherwise ask the library the same question.
353 let collections = state.library.collections();
354 let collection = collections.iter().any(|collection| collection.active);
355
356 if samples.is_empty() {
357 // The sentence and the way out are both on the node rather than on the
358 // region: `703f4cd2` settled that a region with a heading and no rows
359 // still has content, so emptiness belongs to the thing that is empty.
360 Slot::new(BODY, RegionKind::Pane).with(
361 Node::empty("Nothing here yet.")
362 .offering(Act::new("Import samples", Action::get("/import/open"))),
363 )
364 } else {
365 Slot::new(BODY, RegionKind::Pane).with(Node::Table {
366 columns: columns(state, shown),
367 rows: samples
368 .iter()
369 .map(|sample| row(sample, shown, current, collection, &collections))
370 .collect(),
371 // Everything the app has loaded and filtered is here. Windowing
372 // rows it already holds is a renderer's job, which is the note in
373 // this module's header.
374 more: None,
375 })
376 }
377 }
378
379 /// The columns, in the order the shipped list puts them.
380 ///
381 /// Nearly a copy of `ui::file_list::describe`, and that is the point: it already
382 /// built `makeover_layout::Column`s. What is added is the one thing that file
383 /// could not say — the address a heading calls — which is
384 /// [`Column::reorder`] and is quasi's rather than the vocabulary's.
385 fn columns(state: &Panels<'_>, shown: super::ColumnsShown) -> Vec<Column> {
386 let (by, ascending) = state.files.sort();
387 let sorted_by = |name: &str| {
388 (name == by).then_some(if ascending {
389 Sort::Ascending
390 } else {
391 Sort::Descending
392 })
393 };
394 let data = |name: &'static str, priority: Priority| {
395 let mut column = Column::new(name)
396 .width(if name == NAME {
397 Width::Fill
398 } else {
399 Width::Fixed
400 })
401 .priority(priority);
402 column.sorted = sorted_by(name);
403 if sortable(name) {
404 column = column.reorder(Action::post(format!("/files/sort/{name}")));
405 }
406 column
407 };
408
409 let mut columns = vec![data(NAME, Priority::Essential)];
410 if shown.duration {
411 columns.push(data(DUR, Priority::Secondary));
412 }
413 if shown.bpm {
414 columns.push(data(BPM, Priority::Secondary));
415 }
416 if shown.key {
417 columns.push(data(KEY, Priority::Secondary));
418 }
419 if shown.peak_db {
420 columns.push(data(PEAK, Priority::Optional));
421 }
422 if shown.tags {
423 columns.push(data(TAGS, Priority::Optional));
424 }
425 // The play control is essential, because a list of samples you cannot hear
426 // is a list of filenames.
427 columns.push(data(PLAY, Priority::Essential));
428 columns
429 }
430
431 /// One sample as a row.
432 fn row(
433 sample: &Sample,
434 shown: super::ColumnsShown,
435 current: Option<i64>,
436 collection: bool,
437 collections: &[Collection],
438 ) -> Cells {
439 let mut values = vec![Cell::new(&sample.name)];
440 if shown.duration {
441 values.push(Cell::new(seconds(sample.duration)));
442 }
443 if shown.bpm {
444 values.push(Cell::new(
445 sample
446 .bpm
447 .map_or_else(String::new, |bpm| format!("{bpm:.0}")),
448 ));
449 }
450 if shown.key {
451 values.push(Cell::new(sample.key.clone().unwrap_or_default()));
452 }
453 if shown.peak_db {
454 values.push(Cell::new(
455 sample
456 .peak_db
457 .map_or_else(String::new, |db| format!("{db:.1}")),
458 ));
459 }
460 if shown.tags {
461 // Tags as tokens rather than as joined prose, which is what
462 // `RowPart::Tokens` was added for one level down: a tag keeps its own
463 // edges instead of becoming a comma in a sentence.
464 // `Cell::tag` for the first and `token` for the rest: a cell holds a
465 // run, and a tag keeps its own edges rather than becoming a comma in a
466 // sentence, which is what `RowPart::Tokens` was added for one level
467 // down.
468 let mut cell = match sample.tags.first() {
469 Some(first) => Cell::tag(Tag::badge(first.clone())),
470 None => Cell::new(""),
471 };
472 for tag in sample.tags.iter().skip(1) {
473 cell = cell.token(Tag::badge(tag.clone()));
474 }
475 values.push(cell);
476 }
477 // A folder has nothing to play, and the cell stays because cells are
478 // positional against the columns: dropping it would shift every value after
479 // it one column left. Empty rather than absent is the same answer the
480 // analysis cells already give for a folder.
481 values.push(if sample.directory || sample.cloud_only {
482 Cell::new("")
483 } else {
484 Cell::acts([Act::new(
485 "Play",
486 Action::post(format!("/files/{}/play", sample.id)),
487 )])
488 });
489
490 let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id)));
491 row.current = current == Some(sample.id);
492 row.menu = menu(sample, collection, collections);
493 row
494 }
495
496 /// What a row offers without showing it.
497 ///
498 /// `ui/file_list_menus.rs::draw_context_menu`, said as a description. The
499 /// branching is the shipped menu's: a folder and a sample offer different things,
500 /// and a cloud-only sample withholds the four acts that need the bytes on disk.
501 ///
502 /// # What is not here, and why each one is not a gap
503 ///
504 /// - **The selection menu and the background menu.** Eleven entries and five,
505 /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set
506 /// and the empty-space menu on the folder being shown. Neither has a container
507 /// in the vocabulary -- there is no menu over a selection and none over a
508 /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over)
509 /// they become app-modal overlays, which is near enough and not exact. Filed as
510 /// `quasi:vocabulary:anchored-menu`; see this module's header.
511 /// # Add to Collection, which needed no submenu after all
512 ///
513 /// This entry was the module's one measured hole and was filed as a vocabulary
514 /// question with three options, all of them bad: flatten it and the menu grows
515 /// by one line per collection, grow [`Act`] a child list and every renderer
516 /// learns nesting for one consumer, or spend a screen and a gesture on a
517 /// chooser.
518 ///
519 /// The premise was stale. [`Act::asking`] landed after that was written, and it
520 /// is exactly this shape: a control that wants a value before it fires, carried
521 /// by every renderer already. So the entry is one act with one
522 /// [`FieldKind::Select`](quasi_router::layout::FieldKind::Select) on it, the
523 /// menu stays one line however many collections exist, and nothing new was
524 /// added to the vocabulary. A submenu was the wrong question — the nesting was
525 /// never the point, picking one of a list was.
526 fn menu(sample: &Sample, collection: bool, collections: &[Collection]) -> Vec<Act> {
527 let id = sample.id;
528 let at = |verb: &str| Action::post(format!("/files/{id}/{verb}"));
529
530 if sample.directory {
531 return vec![
532 Act::new("Open", at("enter")),
533 // Both already described, at addresses of their own: the folder
534 // modals are `naming`'s, and pointing the menu at them is the whole
535 // benefit of a description having addresses.
536 Act::new("New Folder", Action::get("/folders/new")),
537 Act::new("Rename", Action::get(format!("/folders/{id}/rename"))),
538 Act::new("Export...", at("export")),
539 Act::new("Delete", at("delete"))
540 .tone(Tone::Danger)
541 .confirm(format!("Delete {}?", sample.name)),
542 ];
543 }
544
545 let mut acts = Vec::new();
546
547 // A sample nobody has fetched yet: the one act it does offer, and then
548 // nothing that needs the file.
549 if sample.cloud_only {
550 acts.push(Act::new("Download", at("download")));
551 } else {
552 acts.push(Act::new("Preview", at("play")));
553 }
554
555 acts.push(Act::new("Copy Path", at("path/copy")));
556
557 if !sample.cloud_only {
558 acts.push(Act::new(
559 crate::ui::file_list_menus::reveal_label(),
560 at("reveal"),
561 ));
562 }
563
564 // The two searches carry the keys the detail pane already binds for them, so
565 // one screen does not teach a different chord for the same act.
566 acts.push(Act::new("Find Similar", at("similar")).key("shift+f"));
567 acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d"));
568
569 // A collection to put it in, if there is one. Offered for a cloud-only
570 // sample too: membership is a fact about the sample rather than about the
571 // bytes, which is the same reason the shipped menu guards this on the hash
572 // and not on `cloud_only`.
573 if !collections.is_empty() {
574 acts.push(
575 Act::new("Add to Collection", at("collection/add")).asking(Field::select(
576 COLLECTION,
577 "Collection",
578 collections
579 .iter()
580 .map(|it| Choice::new(it.id.to_string(), it.name.clone()))
581 .collect(),
582 )),
583 );
584 }
585
586 if collection {
587 acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger));
588 }
589
590 if !sample.cloud_only {
591 acts.push(Act::new("Edit...", at("edit")).key("e"));
592 acts.push(Act::new("Play as Instrument", at("instrument")));
593 acts.push(Act::new("Export...", at("export")));
594 acts.push(Act::new("Re-analyze...", at("reanalyze")));
595 }
596
597 acts.push(
598 Act::new("Delete", at("delete"))
599 .tone(Tone::Danger)
600 .confirm(format!("Delete {}?", sample.name)),
601 );
602 acts
603 }
604
605 /// A duration as the list writes it.
606 fn seconds(duration: Option<f64>) -> String {
607 let Some(seconds) = duration else {
608 return String::new();
609 };
610 if seconds < 60.0 {
611 format!("{seconds:.1}s")
612 } else {
613 #[expect(
614 clippy::cast_possible_truncation,
615 clippy::cast_sign_loss,
616 reason = "a sample's length in minutes is small and positive"
617 )]
618 let minutes = (seconds / 60.0) as u32;
619 #[expect(
620 clippy::cast_possible_truncation,
621 clippy::cast_sign_loss,
622 reason = "the remainder is under sixty"
623 )]
624 let rest = (seconds % 60.0) as u32;
625 format!("{minutes}:{rest:02}")
626 }
627 }
628