Skip to main content

max / audiofiles

Build a selection by clicking, and draw the one you have built `1894e95d`. Two regressions from the file-list flip, and the second is the worse of the two: - Ctrl-click and shift-click were gone. Every press posted `/files/{id}/open` and every arrival was `set_single`, so the only multi-select left was the keyboard -- Cmd+A, shift-arrow, Cmd+Shift+I. `ui/file_list.rs::handle_click` read the modifiers itself and went with the file it was in. - Nothing drew a selection. `Cells` could mark the current row and hold a staged tick, and this list's selection is neither, so five hundred rows chosen with Cmd+A looked exactly like a click on one. Together they made the app's own gesture impossible: picking eleven kicks out of a folder of four hundred by hand, which is close to what a sample manager is for. quasi 0.78 is what closes it, and the shape is worth stating because it is not the obvious one. The app keeps the selection -- `nav.selection`, which a dozen features read and the keyboard writes -- and the description says which rows are in it through `Cells::chosen`. What the renderer contributes is the one half only it can know: what the press *meant*, as `Choosing`, because ctrl-click is a pointer host's idiom and a terminal's is a different key. So `/files/{id}/open` reads `Choosing` and `Intent::Choose` applies the three writes the app already had -- `set_single`, `toggle`, `extend_to` -- and nothing here reads a keyboard. `Files::open` stays for what it was always for: the row-menu routes that select a row and then call the detail handle that acts on the sample in focus.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0153yKAYUmoC9bZz2eV6cA88
Author: Max Johnson <me@maxj.phd> · 2026-08-29 16:11 UTC
Signed with PGP, not checked
Commit: 7c97561708eb5161076870af2e1f12e4ff4529aa
Parent: 2317f9f
6 files changed, +178 insertions, -55 deletions
M Cargo.lock +16 -16
@@ -3074,9 +3074,9 @@
3074 3074
3075 3075 [[package]]
3076 3076 name = "makeover-immediate"
3077 - version = "0.39.0"
3077 + version = "0.40.0"
3078 3078 source = "registry+https://github.com/rust-lang/crates.io-index"
3079 - checksum = "628f2e478a4bd00a305cc7b43ee1bdc229ccd8dd9c7077ba7d7cc1f85ba6994c"
3079 + checksum = "ebf95b374f071aac2094c22d1d90ee565ae920adb9950195c072352ee4da2f01"
3080 3080 dependencies = [
3081 3081 "egui",
3082 3082 "egui_extras",
@@ -3086,9 +3086,9 @@
3086 3086
3087 3087 [[package]]
3088 3088 name = "makeover-layout"
3089 - version = "0.38.0"
3089 + version = "0.39.0"
3090 3090 source = "registry+https://github.com/rust-lang/crates.io-index"
3091 - checksum = "73ad4a03ed87470b838bd84be586739682f0a1210db1d7ad4a0146cb9ef7734b"
3091 + checksum = "253ab4630d39ec39233020b81024773da52e93b15544217034ce6ca083ba9227"
3092 3092
3093 3093 [[package]]
3094 3094 name = "makeover-timing"
@@ -4256,7 +4256,7 @@
4256 4256
4257 4257 [[package]]
4258 4258 name = "quasi-immediate"
4259 - version = "0.77.0"
4259 + version = "0.78.0"
4260 4260 dependencies = [
4261 4261 "docengine",
4262 4262 "egui",
@@ -4267,7 +4267,7 @@
4267 4267
4268 4268 [[package]]
4269 4269 name = "quasi-router"
4270 - version = "0.77.0"
4270 + version = "0.78.0"
4271 4271 dependencies = [
4272 4272 "makeover-layout",
4273 4273 ]
@@ -7556,21 +7556,25 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 + [[patch.unused]]
7560 + name = "quasi-type"
7561 + version = "0.1.2"
7562 +
7559 7563 [[patch.unused]]
7560 7564 name = "quasi-axum"
7561 - version = "0.77.0"
7565 + version = "0.78.0"
7562 7566
7563 7567 [[patch.unused]]
7564 7568 name = "quasi-basics"
7565 - version = "0.77.0"
7569 + version = "0.78.0"
7566 7570
7567 7571 [[patch.unused]]
7568 7572 name = "quasi-http"
7569 - version = "0.77.0"
7573 + version = "0.78.0"
7570 7574
7571 7575 [[patch.unused]]
7572 7576 name = "quasi-notifs"
7573 - version = "0.77.0"
7577 + version = "0.78.0"
7574 7578
7575 7579 [[patch.unused]]
7576 7580 name = "quasi-store"
@@ -7578,15 +7582,11 @@
7578 7582
7579 7583 [[patch.unused]]
7580 7584 name = "quasi-tauri"
7581 - version = "0.77.0"
7585 + version = "0.78.0"
7582 7586
7583 7587 [[patch.unused]]
7584 7588 name = "quasi-webview"
7585 - version = "0.77.0"
7586 -
7587 - [[patch.unused]]
7588 - name = "quasi-type"
7589 - version = "0.1.2"
7589 + version = "0.78.0"
7590 7590
7591 7591 [[patch.unused]]
7592 7592 name = "kberg"
M Cargo.toml +4 -4
@@ -17,8 +17,8 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.38.0"
21 - makeover-immediate = "0.39.0"
20 + makeover-layout = "0.39.0"
21 + makeover-immediate = "0.40.0"
22 22 # The time axis. Minor rather than exact patch: it re-exports nothing and no
23 23 # type of its crosses a renderer boundary, so it is under the same rule as
24 24 # makeover-geometry above rather than the layout/immediate pair below.
@@ -26,8 +26,8 @@
26 26 # The described screens, which are audiofiles-browser's screens. By git URL with
27 27 # a version requirement, per the tree's rule for cross-repo deps. They were
28 28 # behind a `quasi` feature until 2026-08-25, when the last flip landed.
29 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.77" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.77" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.78" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.78" }
31 31 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
32 32 egui_extras = { version = "0.35", default-features = false }
33 33 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -91,34 +91,42 @@
91 91 //! the subject, and the two menus turn out to differ in both.
92 92 //!
93 93 //! - **The selection menu hangs off the row the press landed on**, as
94 - //! `chosen_menu` below. quasi 0.77 ships
95 - //! [`Anchor::Selection`](quasi_router::Anchor::Selection) for exactly this and
96 - //! it does not fit here: that member resolves against the *description's*
97 - //! selection — `Screen::selection` named, rows ticked — and this list's
98 - //! selection is the app's own, live and unticked, for the reason `row` below
99 - //! records. So the anchor would resolve to nothing and the menu would open
100 - //! away from its subject. Ruled by Max 2026-08-29 on the measurement; the row
101 - //! the press landed on is the anchor, which is what the shipped list did.
94 + //! `chosen_menu` below. Ruled by Max 2026-08-29: that is where every file
95 + //! manager opens it, and it opens at the pointer rather than at a corner of
96 + //! the list. [`Anchor::Selection`](quasi_router::Anchor::Selection) exists and
97 + //! now resolves here — quasi 0.78 taught it to read a live selection and not
98 + //! only a staged one — so this is a choice about where a context menu belongs
99 + //! rather than a limit being worked around.
102 100 //! - **The empty-space menu is anchored at the region**, as `background_menu`,
103 101 //! because a press on nothing has no row. It is the app's first
104 102 //! [`Outcome::Anchored`](quasi_router::Outcome::Anchored), and its gesture is
105 103 //! the host's — `panel::menued` — since the absence of a row is not a
106 104 //! described thing for a description to answer about.
107 105 //!
108 - //! What is still owed, and it is the reason the first of those went the way it
109 - //! did: **a live multi-selection has no described shape.** `Cells` marks the
110 - //! current row and holds a tick, a tick is a staged set awaiting a commit
111 - //! control, and this list's selection is neither — so a five-hundred-row Cmd+A
112 - //! draws the same as a one-row click, and [`Sample::selected`] is read by the
113 - //! menu fork and shown nowhere. Measured 2026-08-29 and filed against
114 - //! quasicoherent.
106 + //! # The live selection, which had no described shape until 2026-08-29
107 + //!
108 + //! `1894e95d`, and it was worse than the menus: `Cells` could mark the current
109 + //! row and hold a staged tick, and this list's selection is neither, so nothing
110 + //! drew it — a five-hundred-row Cmd+A looked exactly like a click on one — and
111 + //! nothing could say a press was ctrl-held, so the only multi-select left was
112 + //! the keyboard. Picking eleven kicks out of four hundred by hand, which is
113 + //! close to what this app is for, could not be done at all.
114 + //!
115 + //! quasi 0.78 is the answer and it is two members. [`Cells::chosen`] is the
116 + //! third state beside `current` and `selected`, three-valued for the reason the
117 + //! tick is: a ctrl-click lands on an **unchosen** row, so a renderer has to know
118 + //! that row can join a set before anything is in one.
119 + //! [`Choosing`] is what a press meant — `Only`, `Also`, `Through` — said by the
120 + //! renderer because the idiom is the host's and carried under
121 + //! [`Node::CHOOSING`]. The app still owns the set; what it gets told is what the
122 + //! reader asked for.
115 123 //! - **Virtual scrolling.** Recorded in the findings note as renderer policy
116 124 //! from the start: windowing rows the app already holds is a performance
117 125 //! technique, not a described fact.
118 126
119 127 use quasi_router::layout::{Priority, Sort, Tone, Width};
120 128 use quasi_router::{
121 - Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response,
129 + Act, Action, Cell, Cells, Choice, Choosing, Column, Field, Node, RegionKind, Request, Response,
122 130 RouteError, Router, Screen, Slot, Tag,
123 131 };
124 132
@@ -192,9 +200,19 @@
192 200 }
193 201
194 202 /// `POST /files/{id}/open`
203 + ///
204 + /// A press on a row, which in a file list is how a selection is built as well as
205 + /// how one row is picked. What the press *meant* is the renderer's answer and
206 + /// arrives in the payload; `1894e95d` and [`Choosing`] carry the reasoning.
207 + ///
208 + /// A request that names none -- a hand-typed one, or a renderer that has not
209 + /// learned the member -- reads as the plain press, which is what this route did
210 + /// before the member existed.
195 211 fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
196 212 let id = id_of(&request)?;
197 - state.files.open(id);
213 + state
214 + .files
215 + .choose(id, Choosing::read(request.payload.get(Node::CHOOSING)));
198 216 Ok(screen(state).into())
199 217 }
200 218
@@ -751,17 +769,20 @@
751 769 )])
752 770 });
753 771
754 - // Named, so the renderer can answer which row the pointer is over with the
755 - // sample rather than with a position. `identified` and not `ticking`: this
756 - // list's selection is the app's -- `state.nav.selection` -- so there is no
757 - // tick to set, and the two were only ever written together because every
758 - // list that wanted an identity before this one also wanted a checkbox.
772 + // Named and chosen, which is one call because they are one fact: the id the
773 + // renderer answers a gesture with, and this row's standing in the selection
774 + // that gesture is building. `choosing` and not `ticking` -- a tick is a
775 + // staged set awaiting a commit control, and this list's selection is the
776 + // app's own and already in force. `1894e95d`.
759 777 //
760 - // What it closes: `panel::dragging_out` used to have to match `row_at`'s
761 - // index back against the sample ordering this description was built from,
762 - // which is the same row identified twice in two places, agreeing by luck.
778 + // What the name closes: `panel::dragging_out` used to have to match
779 + // `row_at`'s index back against the sample ordering this description was
780 + // built from, which is the same row identified twice in two places, agreeing
781 + // by luck. What the chosen half closes is bigger -- until it landed nothing
782 + // drew a multi-selection at all, so Cmd+A over five hundred samples looked
783 + // exactly like a click on one.
763 784 let mut row = Cells::new(values)
764 - .identified(sample.id.to_string())
785 + .choosing(sample.id.to_string(), sample.selected)
765 786 .activate(Action::post(format!("/files/{}/open", sample.id)));
766 787 row.current = listing.current == Some(sample.id);
767 788 // Which menu a row offers is which subject the press is about. A row inside
@@ -611,12 +611,12 @@
611 611 /// unchosen one. What reads it is `files::menu`, which offers a row inside a
612 612 /// multi-selection what can be done to the *set* rather than to the row.
613 613 ///
614 - /// Worth knowing where this stops: nothing **shows** it. `Cells` marks the
615 - /// current row and holds a tick, and a tick is a staged set awaiting a
616 - /// commit control rather than a selection that is already in force, so the
617 - /// list draws a five-hundred-row Cmd+A the same as a one-row click. Measured
618 - /// 2026-08-29 and filed; it is why `Anchor::Selection` resolves against
619 - /// nothing here and why this menu hangs off the row instead.
614 + /// It is also what the list *draws*. Until quasi 0.78 nothing could: `Cells`
615 + /// marked the current row and held a tick, a tick is a staged set awaiting a
616 + /// commit control rather than a selection already in force, and a
617 + /// five-hundred-row Cmd+A therefore looked exactly like a one-row click.
618 + /// `Cells::chosen` is the third state that closes it, and `Cells::choosing`
619 + /// is what `files::row` writes this into.
620 620 pub selected: bool,
621 621 /// Whether this sample is in the basket -- one of the samples being looked
622 622 /// *for*, rather than one of the answers.
@@ -668,9 +668,24 @@
668 668 /// The row the app is pointing at.
669 669 fn current(&self) -> Option<i64>;
670 670
671 - /// Select this row.
671 + /// Select this row, and nothing else.
672 + ///
673 + /// What the routes that borrow a capability call before they borrow it: the
674 + /// detail handle acts on the sample in focus, so a row menu selects the row
675 + /// first. Not what a *press* on a row calls -- that is
676 + /// [`choose`](Self::choose), which carries what the press meant.
672 677 fn open(&self, id: i64);
673 678
679 + /// Answer a press on this row, which may be building a selection.
680 + ///
681 + /// `1894e95d`. The file list's selection is live: click chooses one row,
682 + /// ctrl-click adds or removes one, shift-click takes the run. The
683 + /// description cannot say which of those a press was, because that is the
684 + /// host's own idiom -- so the renderer says, under
685 + /// [`Choosing`](quasi_router::Choosing), and this carries it to the app that
686 + /// owns the set.
687 + fn choose(&self, id: i64, meant: quasi_router::Choosing);
688 +
674 689 /// Preview this row.
675 690 fn play(&self, id: i64);
676 691
@@ -780,8 +795,11 @@
780 795 /// submitted, and a gain is an `f64`.
781 796 #[derive(Debug, Clone, PartialEq)]
782 797 pub enum Intent {
783 - /// Select a row.
798 + /// Select a row, and nothing else.
784 799 Open(i64),
800 + /// Answer a press on a row, which may be adding to the selection or taking
801 + /// a run.
802 + Choose(i64, quasi_router::Choosing),
785 803 /// Preview a row.
786 804 Play(i64),
787 805 /// Go into a folder.
@@ -1345,6 +1363,10 @@
1345 1363 self.intents.borrow_mut().push(Intent::Open(id));
1346 1364 }
1347 1365
1366 + fn choose(&self, id: i64, meant: quasi_router::Choosing) {
1367 + self.intents.borrow_mut().push(Intent::Choose(id, meant));
1368 + }
1369 +
1348 1370 fn play(&self, id: i64) {
1349 1371 self.intents.borrow_mut().push(Intent::Play(id));
1350 1372 }
@@ -579,6 +579,30 @@
579 579 state.refresh_selected_detail();
580 580 }
581 581 }
582 + // A press on a row, which in a file list is how a selection is
583 + // built. `1894e95d`, and it is `ui/file_list.rs::handle_click`
584 + // restored: the deleted list read the modifiers itself, and what
585 + // replaces that reading is `Choosing` -- said by the renderer,
586 + // carried by the description, applied here.
587 + //
588 + // The three writes are the app's own and were never gone; what went
589 + // with the flip was any way to reach two of them. `extend_to` takes
590 + // `visible_len` rather than the row count because the run it walks
591 + // is the rows the *list* shows, `..` included.
592 + Intent::Choose(id, meant) => {
593 + if let Some(at) = index_of(state, id) {
594 + match meant {
595 + quasi_router::Choosing::Only => state.nav.selection.set_single(at),
596 + quasi_router::Choosing::Also => state.nav.selection.toggle(at),
597 + quasi_router::Choosing::Through => {
598 + let len = state.visible_len();
599 + state.nav.selection.extend_to(at, len);
600 + }
601 + }
602 + state.refresh_selected_tags();
603 + state.refresh_selected_detail();
604 + }
605 + }
582 606 Intent::Play(id) => {
583 607 if let Some(at) = index_of(state, id) {
584 608 state.nav.selection.set_single(at);
@@ -117,6 +117,11 @@
117 117 fn open(&self, id: i64) {
118 118 self.asked.borrow_mut().push(format!("open:{id}"));
119 119 }
120 + fn choose(&self, id: i64, meant: quasi_router::Choosing) {
121 + self.asked
122 + .borrow_mut()
123 + .push(format!("choose:{id}:{}", meant.as_str()));
124 + }
120 125 fn play(&self, id: i64) {
121 126 self.asked.borrow_mut().push(format!("play:{id}"));
122 127 }
@@ -1963,7 +1968,58 @@
1963 1968 listing(&files, Request::post("/files/7/open")).expect("answered");
1964 1969 listing(&files, Request::post("/files/7/play")).expect("answered");
1965 1970 listing(&files, Request::post("/files/sort/BPM")).expect("answered");
1966 - assert_eq!(files.asked(), ["open:7", "play:7", "sort:BPM"]);
1971 + // A press with nothing said about it is the plain one, which is what this
1972 + // route did before it could be told. See the test below.
1973 + assert_eq!(files.asked(), ["choose:7:only", "play:7", "sort:BPM"]);
1974 + }
1975 +
1976 + /// `1894e95d`. Ctrl-click and shift-click, which the described list lost in
1977 + /// `49b7429` and which are how anyone builds a selection in a file manager.
1978 + ///
1979 + /// The description cannot say which of the three a press was -- that is the
1980 + /// host's own idiom, and a terminal's is not a keyboard modifier -- so the
1981 + /// renderer says under `Node::CHOOSING` and this route carries it through.
1982 + #[test]
1983 + fn a_press_says_whether_it_is_building_a_selection() {
1984 + let files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]);
1985 + for meant in ["only", "also", "through"] {
1986 + let request = Request::post("/files/9/open")
1987 + .sending(Params::new().with(quasi_router::Node::CHOOSING.to_owned(), meant.to_owned()));
1988 + listing(&files, request).expect("answered");
1989 + }
1990 + // A spelling this version does not know is the plain press rather than a
1991 + // refusal: it is still a press on a row, and the safe reading chooses the
1992 + // row that was pressed and nothing else.
1993 + let odd = Request::post("/files/9/open")
1994 + .sending(Params::new().with(quasi_router::Node::CHOOSING.to_owned(), "lasso".to_owned()));
1995 + listing(&files, odd).expect("answered");
1996 +
1997 + assert_eq!(
1998 + files.asked(),
1999 + [
2000 + "choose:9:only",
2001 + "choose:9:also",
2002 + "choose:9:through",
2003 + "choose:9:only",
2004 + ]
2005 + );
2006 + }
2007 +
2008 + /// The rows say which of them are chosen, which is what lets a renderer draw a
2009 + /// selection at all -- and what `chosen_menu` forks on.
2010 + #[test]
2011 + fn the_rows_say_which_of_them_the_reader_has_chosen() {
2012 + let files = FakeFiles::with(vec![chosen(7, "kick.wav"), sample(9, "snare.wav")]);
2013 + let response = listing(&files, Request::get("/files")).expect("answered");
2014 + let (_, rows) = table_of(screen_of(&response));
2015 +
2016 + // Three states, not two: the second row can be chosen and is not, which is
2017 + // what a ctrl-click on it depends on the renderer knowing.
2018 + assert_eq!(rows[0].chosen, Some(true));
2019 + assert_eq!(rows[1].chosen, Some(false));
2020 + // And the value the app reads the press back by is the sample's own id, the
2021 + // same one the drag reads off `row_at`.
2022 + assert_eq!(rows[0].value.as_deref(), Some("7"));
1967 2023 }
1968 2024
1969 2025 #[test]