max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
4 files changed,
+143 insertions,
-109 deletions
| @@ -66,7 +66,7 @@ | |||
| 66 | 66 | use quasi_router::layout::{FieldKind, Tone}; | |
| 67 | 67 | use quasi_router::{ | |
| 68 | 68 | Act, Action, Cell, Cells, Choice, Column, Consult, Field, Node, Outcome, RegionKind, Request, | |
| 69 | - | Response, Rest, RouteError, Router, Screen, Slot, Tag, | |
| 69 | + | Response, Rest, RouteError, Router, Screen, Slot, Table, Tag, | |
| 70 | 70 | }; | |
| 71 | 71 | ||
| 72 | 72 | use super::{Chosen, Panels}; | |
| @@ -80,6 +80,12 @@ | |||
| 80 | 80 | const MODE: &str = "mode"; | |
| 81 | 81 | /// What a move modal submits. | |
| 82 | 82 | const FOLDER: &str = "folder"; | |
| 83 | + | /// The move modal's one column heading, named by the table and by its rows. | |
| 84 | + | /// | |
| 85 | + | /// A const rather than the string twice, because [`row`] is written a screen | |
| 86 | + | /// away from the column list it answers and a heading that drifted in one of | |
| 87 | + | /// the two places would be an empty cell rather than a compile error. | |
| 88 | + | const DESTINATION: &str = "Folder"; | |
| 83 | 89 | /// What a rename modal submits. | |
| 84 | 90 | const PATTERN: &str = "pattern"; | |
| 85 | 91 | ||
| @@ -301,20 +307,24 @@ | |||
| 301 | 307 | let body = Slot::new(BODY, RegionKind::Pane) | |
| 302 | 308 | .with(Node::page(format!("Move {} items", chosen.names.len()))) | |
| 303 | 309 | .with(Node::text("Choose where they go.")) | |
| 304 | - | .with(Node::Table { | |
| 305 | - | columns: vec![Column::new("Folder")], | |
| 306 | - | rows, | |
| 307 | - | // Every folder in the vault, because a destination the picker does | |
| 308 | - | // not show is a destination you cannot choose. | |
| 309 | - | more: None, | |
| 310 | - | }); | |
| 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 | + | )); | |
| 311 | 315 | ||
| 312 | 316 | closing(subjects(body, &chosen.names)) | |
| 313 | 317 | } | |
| 314 | 318 | ||
| 315 | 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. | |
| 316 | 324 | fn row(path: &str, value: &str) -> Cells { | |
| 317 | - | Cells::new(vec![Cell::new(path)]).activate(Action::post("/bulk/move").carrying(FOLDER, value)) | |
| 325 | + | Cells::default() | |
| 326 | + | .at(DESTINATION, Cell::new(path)) | |
| 327 | + | .activate(Action::post("/bulk/move").carrying(FOLDER, value)) | |
| 318 | 328 | } | |
| 319 | 329 | ||
| 320 | 330 | /// `GET /bulk/rename` | |
| @@ -457,33 +467,37 @@ | |||
| 457 | 467 | } | |
| 458 | 468 | ||
| 459 | 469 | let total = previews.len(); | |
| 460 | - | Node::Table { | |
| 461 | - | columns: vec![Column::new("Old"), Column::new("New")], | |
| 462 | - | // Said rather than implied, as of quasi 0.15: a described table of the | |
| 463 | - | // first fifty of five hundred was indistinguishable from a table of | |
| 464 | - | // fifty until `Node::Table` grew `more`, which is the gap this port | |
| 465 | - | // noted at 65abb3c. No `forward`, because there is nowhere to ask -- | |
| 466 | - | // the cap is a rendering budget and the rename acts on all of them. | |
| 467 | - | more: (total > SHOWN).then(|| Rest::showing(SHOWN).of(total)), | |
| 468 | - | rows: previews | |
| 469 | - | .iter() | |
| 470 | - | .take(SHOWN) | |
| 471 | - | .map(|(old, new)| { | |
| 472 | - | let collides = seen.get(new.as_str()).copied().unwrap_or(0) > 1; | |
| 473 | - | Cells::new(vec![ | |
| 474 | - | Cell::new(old), | |
| 475 | - | // A collision is a tone on the value rather than a hover on | |
| 476 | - | // it, for the reason a suggestion's score is in its label in | |
| 477 | - | // `detail`: a reader with no pointer never sees a hover, and | |
| 478 | - | // this one is a warning about losing files. | |
| 479 | - | if collides { | |
| 480 | - | Cell::tag(Tag::badge(new.clone()).tone(Tone::Warning)) | |
| 481 | - | } else { | |
| 482 | - | Cell::new(new) | |
| 483 | - | }, | |
| 484 | - | ]) | |
| 485 | - | }) | |
| 486 | - | .collect(), | |
| 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() | |
| 487 | 501 | } | |
| 488 | 502 | } | |
| 489 | 503 |
| @@ -273,7 +273,7 @@ | |||
| 273 | 273 | /// channel count are properties of a file, not headline figures. The shipped | |
| 274 | 274 | /// panel draws an `egui::Grid` of label/value pairs and that is what this is. | |
| 275 | 275 | fn metadata(body: Slot, analysis: &Analysis) -> Slot { | |
| 276 | - | use quasi_router::{Cell, Cells, Column}; | |
| 276 | + | use quasi_router::{Cell, Cells, Column, Table}; | |
| 277 | 277 | ||
| 278 | 278 | let mut rows = vec![ | |
| 279 | 279 | fact("Duration", seconds(analysis.duration)), | |
| @@ -299,15 +299,18 @@ | |||
| 299 | 299 | rows.push(fact("Loop", if is_loop { "Yes" } else { "No" })); | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | body.with(Node::section("Metadata")).with(Node::Table { | |
| 303 | - | columns: vec![Column::new("Field"), Column::new("Value")], | |
| 304 | - | rows: rows | |
| 305 | - | .into_iter() | |
| 306 | - | .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)])) | |
| 307 | - | .collect(), | |
| 308 | - | // Nine fields at most, and every one that was found is here. | |
| 309 | - | more: None, | |
| 310 | - | }) | |
| 302 | + | // Positional cells, and they stay that way: the two columns and the two | |
| 303 | + | // cells are written in the same expression, and it is the *rows* that come | |
| 304 | + | // and go here rather than the cells within one. Every row is a field and a | |
| 305 | + | // value, so there is no conditional cell to shift the ones behind it. | |
| 306 | + | // | |
| 307 | + | // No `more`: nine fields at most, and every one that was found is here. | |
| 308 | + | body.with(Node::section("Metadata")).with(Node::from( | |
| 309 | + | Table::new(vec![Column::new("Field"), Column::new("Value")]).rows( | |
| 310 | + | rows.into_iter() | |
| 311 | + | .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)])), | |
| 312 | + | ), | |
| 313 | + | )) | |
| 311 | 314 | } | |
| 312 | 315 | ||
| 313 | 316 | /// One label and one value. | |
| @@ -487,20 +490,21 @@ | |||
| 487 | 490 | ||
| 488 | 491 | /// What every chosen sample says, where they say the same thing. | |
| 489 | 492 | fn agreed(body: Slot, spread: &Spread) -> Slot { | |
| 490 | - | use quasi_router::{Cell, Cells, Column}; | |
| 493 | + | use quasi_router::{Cell, Cells, Column, Table}; | |
| 491 | 494 | ||
| 492 | - | body.with(Node::section("In common")).with(Node::Table { | |
| 493 | - | columns: vec![Column::new("Field"), Column::new("Value")], | |
| 494 | - | rows: [ | |
| 495 | - | ("BPM", &spread.bpm), | |
| 496 | - | ("Key", &spread.musical_key), | |
| 497 | - | ("Duration", &spread.duration), | |
| 498 | - | ] | |
| 499 | - | .into_iter() | |
| 500 | - | .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(reads(value))])) | |
| 501 | - | .collect(), | |
| 502 | - | more: None, | |
| 503 | - | }) | |
| 495 | + | // Three fixed rows of two cells, with the columns beside them in the one | |
| 496 | + | // expression, so position is still safe to read here. | |
| 497 | + | body.with(Node::section("In common")).with(Node::from( | |
| 498 | + | Table::new(vec![Column::new("Field"), Column::new("Value")]).rows( | |
| 499 | + | [ | |
| 500 | + | ("BPM", &spread.bpm), | |
| 501 | + | ("Key", &spread.musical_key), | |
| 502 | + | ("Duration", &spread.duration), | |
| 503 | + | ] | |
| 504 | + | .into_iter() | |
| 505 | + | .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(reads(value))])), | |
| 506 | + | ), | |
| 507 | + | )) | |
| 504 | 508 | } | |
| 505 | 509 | ||
| 506 | 510 | /// What a shared field reads as. |
| @@ -104,7 +104,7 @@ | |||
| 104 | 104 | use quasi_router::layout::{Priority, Sort, Tone, Width}; | |
| 105 | 105 | use quasi_router::{ | |
| 106 | 106 | Act, Action, Cell, Cells, Choice, Choosing, Column, Field, Node, RegionKind, Request, Response, | |
| 107 | - | RouteError, Router, Screen, Slot, Tag, | |
| 107 | + | RouteError, Router, Screen, Slot, Table, Tag, | |
| 108 | 108 | }; | |
| 109 | 109 | ||
| 110 | 110 | use super::{Collection, Panels, Sample}; | |
| @@ -579,14 +579,13 @@ | |||
| 579 | 579 | .offering(Act::new("Import samples", Action::get("/import/open"))), | |
| 580 | 580 | ) | |
| 581 | 581 | } else { | |
| 582 | - | Slot::new(BODY, RegionKind::Pane).with(Node::Table { | |
| 583 | - | columns: columns(state, shown), | |
| 584 | - | rows: samples.iter().map(|sample| row(sample, &listing)).collect(), | |
| 585 | - | // Everything the app has loaded and filtered is here. Windowing | |
| 586 | - | // rows it already holds is a renderer's job, which is the note in | |
| 587 | - | // this module's header. | |
| 588 | - | more: None, | |
| 589 | - | }) | |
| 582 | + | // No `more`: everything the app has loaded and filtered is here. | |
| 583 | + | // Windowing rows it already holds is a renderer's job, which is the | |
| 584 | + | // note in this module's header. | |
| 585 | + | Slot::new(BODY, RegionKind::Pane).with(Node::from( | |
| 586 | + | Table::new(columns(state, shown)) | |
| 587 | + | .rows(samples.iter().map(|sample| row(sample, &listing))), | |
| 588 | + | )) | |
| 590 | 589 | } | |
| 591 | 590 | } | |
| 592 | 591 | ||
| @@ -677,10 +676,18 @@ | |||
| 677 | 676 | } | |
| 678 | 677 | ||
| 679 | 678 | /// One sample as a row. | |
| 679 | + | /// | |
| 680 | + | /// Every cell names its column, and six of the eight are conditional: the | |
| 681 | + | /// anchors cell appears with a basket answer and five more follow the reader's | |
| 682 | + | /// switched-on columns. Built by position, this function and [`columns`] were | |
| 683 | + | /// two lists of conditionals that had to agree in the same order, in two places, | |
| 684 | + | /// with nothing checking that they did. Naming them makes [`columns`] the only | |
| 685 | + | /// place the shape is decided, and a cell whose column is off is dropped rather | |
| 686 | + | /// than shifting everything after it one column left. | |
| 680 | 687 | fn row(sample: &Sample, listing: &Listing<'_>) -> Cells { | |
| 681 | 688 | let shown = listing.shown; | |
| 682 | 689 | let basket_showing = listing.basket_showing; | |
| 683 | - | let mut values = vec![Cell::new(&sample.name)]; | |
| 690 | + | let mut values = Cells::default().at(NAME, Cell::new(&sample.name)); | |
| 684 | 691 | if basket_showing { | |
| 685 | 692 | // A badge per anchor, in basket order, the way the tags cell holds a run | |
| 686 | 693 | // of tags: each anchor keeps its own edges rather than becoming a comma | |
| @@ -694,27 +701,33 @@ | |||
| 694 | 701 | for anchor in sample.matched.iter().skip(1) { | |
| 695 | 702 | cell = cell.token(Tag::badge(anchor.clone())); | |
| 696 | 703 | } | |
| 697 | - | values.push(cell); | |
| 704 | + | values = values.at(NEAR, cell); | |
| 698 | 705 | } | |
| 699 | 706 | if shown.duration { | |
| 700 | - | values.push(Cell::new(seconds(sample.duration))); | |
| 707 | + | values = values.at(DUR, Cell::new(seconds(sample.duration))); | |
| 701 | 708 | } | |
| 702 | 709 | if shown.bpm { | |
| 703 | - | values.push(Cell::new( | |
| 704 | - | sample | |
| 705 | - | .bpm | |
| 706 | - | .map_or_else(String::new, |bpm| format!("{bpm:.0}")), | |
| 707 | - | )); | |
| 710 | + | values = values.at( | |
| 711 | + | BPM, | |
| 712 | + | Cell::new( | |
| 713 | + | sample | |
| 714 | + | .bpm | |
| 715 | + | .map_or_else(String::new, |bpm| format!("{bpm:.0}")), | |
| 716 | + | ), | |
| 717 | + | ); | |
| 708 | 718 | } | |
| 709 | 719 | if shown.key { | |
| 710 | - | values.push(Cell::new(sample.key.clone().unwrap_or_default())); | |
| 720 | + | values = values.at(KEY, Cell::new(sample.key.clone().unwrap_or_default())); | |
| 711 | 721 | } | |
| 712 | 722 | if shown.peak_db { | |
| 713 | - | values.push(Cell::new( | |
| 714 | - | sample | |
| 715 | - | .peak_db | |
| 716 | - | .map_or_else(String::new, |db| format!("{db:.1}")), | |
| 717 | - | )); | |
| 723 | + | values = values.at( | |
| 724 | + | PEAK, | |
| 725 | + | Cell::new( | |
| 726 | + | sample | |
| 727 | + | .peak_db | |
| 728 | + | .map_or_else(String::new, |db| format!("{db:.1}")), | |
| 729 | + | ), | |
| 730 | + | ); | |
| 718 | 731 | } | |
| 719 | 732 | if shown.tags { | |
| 720 | 733 | // Tags as tokens rather than as joined prose, which is what | |
| @@ -731,20 +744,22 @@ | |||
| 731 | 744 | for tag in sample.tags.iter().skip(1) { | |
| 732 | 745 | cell = cell.token(Tag::badge(tag.clone())); | |
| 733 | 746 | } | |
| 734 | - | values.push(cell); | |
| 747 | + | values = values.at(TAGS, cell); | |
| 735 | 748 | } | |
| 736 | - | // A folder has nothing to play, and the cell stays because cells are | |
| 737 | - | // positional against the columns: dropping it would shift every value after | |
| 738 | - | // it one column left. Empty rather than absent is the same answer the | |
| 739 | - | // analysis cells already give for a folder. | |
| 740 | - | values.push(if sample.directory || sample.cloud_only { | |
| 741 | - | Cell::new("") | |
| 742 | - | } else { | |
| 743 | - | Cell::acts([Act::new( | |
| 744 | - | "Play", | |
| 745 | - | Action::post(format!("/files/{}/play", sample.id)), | |
| 746 | - | )]) | |
| 747 | - | }); | |
| 749 | + | // A folder has nothing to play. The cell is said anyway rather than left | |
| 750 | + | // out: the Play column is always on, and empty rather than absent is the | |
| 751 | + | // same answer the analysis cells already give for a folder. | |
| 752 | + | values = values.at( | |
| 753 | + | PLAY, | |
| 754 | + | if sample.directory || sample.cloud_only { | |
| 755 | + | Cell::new("") | |
| 756 | + | } else { | |
| 757 | + | Cell::acts([Act::new( | |
| 758 | + | "Play", | |
| 759 | + | Action::post(format!("/files/{}/play", sample.id)), | |
| 760 | + | )]) | |
| 761 | + | }, | |
| 762 | + | ); | |
| 748 | 763 | ||
| 749 | 764 | // Named and chosen, which is one call because they are one fact: the id the | |
| 750 | 765 | // renderer answers a gesture with, and this row's standing in the selection | |
| @@ -758,7 +773,7 @@ | |||
| 758 | 773 | // by luck. What the chosen half closes is bigger -- until it landed nothing | |
| 759 | 774 | // drew a multi-selection at all, so Cmd+A over five hundred samples looked | |
| 760 | 775 | // exactly like a click on one. | |
| 761 | - | let mut row = Cells::new(values) | |
| 776 | + | let mut row = values | |
| 762 | 777 | .choosing(sample.id.to_string(), sample.selected) | |
| 763 | 778 | .activate(Action::post(format!("/files/{}/open", sample.id))); | |
| 764 | 779 | row.current = listing.current == Some(sample.id); |
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | ||
| 79 | 79 | use quasi_router::{ | |
| 80 | 80 | Action, Cell, Cells, Chrome, Column, Node, RegionKind, Request, Response, RouteError, Router, | |
| 81 | - | Screen, Slot, | |
| 81 | + | Screen, Slot, Table, | |
| 82 | 82 | }; | |
| 83 | 83 | ||
| 84 | 84 | use super::{Panel, Panels}; | |
| @@ -281,20 +281,21 @@ | |||
| 281 | 281 | if let Some(name) = group { | |
| 282 | 282 | sections.push(Node::section(name)); | |
| 283 | 283 | } | |
| 284 | - | sections.push(Node::Table { | |
| 285 | - | columns: vec![Column::new("Key"), Column::new("Does")], | |
| 286 | - | rows: bindings | |
| 287 | - | .iter() | |
| 288 | - | .map(|binding| { | |
| 284 | + | // Positional cells: the two columns and the two cells are written a | |
| 285 | + | // line apart in the one expression, and a binding is always a key and | |
| 286 | + | // a label, so there is no cell here that appears only sometimes. | |
| 287 | + | // | |
| 288 | + | // No `more`: every key that is bound is listed, which is the whole | |
| 289 | + | // claim of this screen. A shortcuts table with something withheld | |
| 290 | + | // would be the drift it exists to end. | |
| 291 | + | sections.push(Node::from( | |
| 292 | + | Table::new(vec![Column::new("Key"), Column::new("Does")]).rows(bindings.iter().map( | |
| 293 | + | |binding| { | |
| 289 | 294 | Cells::new(vec![Cell::new(&binding.key), Cell::new(&binding.label)]) | |
| 290 | 295 | .activate(binding.action.clone()) | |
| 291 | - | }) | |
| 292 | - | .collect(), | |
| 293 | - | // Every key that is bound is listed, which is the whole claim of | |
| 294 | - | // this screen. A shortcuts table with something withheld would be | |
| 295 | - | // the drift it exists to end. | |
| 296 | - | more: None, | |
| 297 | - | }); | |
| 296 | + | }, | |
| 297 | + | )), | |
| 298 | + | )); | |
| 298 | 299 | } | |
| 299 | 300 | sections | |
| 300 | 301 | } |