Skip to main content

max / audiofiles

14.1 KB · 337 lines History Blame Raw
1 //! The sidebar, described: vaults, collections, and the tags you can filter by.
2 //!
3 //! The ninth audiofiles port. It completes the main window's regions — the shell
4 //! now has a `Sidebar`, a `Pane` and a `Band`, which is every region kind this
5 //! app has a use for — and it is the first port where a described control
6 //! *replaces* the app's confirmation machinery rather than merely arguing that
7 //! it could.
8 //!
9 //! # `Act::confirm` doing the job `ConfirmAction` was doing
10 //!
11 //! `quasi/mod.rs`'s header counts `draw_confirm_dialog` as ten variants
12 //! replaceable by two builder methods. Two of those variants are here and are
13 //! now written the other way:
14 //!
15 //! - `ConfirmAction::DeleteVfs` is `Act::new("Delete", ..).tone(Danger).confirm("Delete
16 //! vault \"x\" and all its contents?")` on the vault's row menu.
17 //! - `ConfirmAction::RemoveTagGlobally` is the same shape on a tag's.
18 //!
19 //! The shipped path for either is: a context menu writes `pending_confirm`, a
20 //! 140-line `match` in `overlays.rs` turns the variant back into a prompt and a
21 //! button label, a modal draws it, and `execute_confirmed_action` dispatches on
22 //! the variant again to find what to do. The described path is one method on the
23 //! control, and the runtime answers `Step::Ask`. **The prompt lives where the
24 //! action does**, which is the whole of `524a63fe`'s argument, and the round trip
25 //! through an enum is what a description makes unnecessary rather than shorter.
26 //!
27 //! # THE FINDING: a hierarchy of rows has no description
28 //!
29 //! audiofiles' tags are dotted — `drums.kick`, `genre.house` — and the shipped
30 //! sidebar builds a real tree out of them: `TagNode` with `children`, a
31 //! recursive `draw_tag_node`, a disclosure chevron that is deliberately a
32 //! separate hit target from the label, per-node expansion persisted by egui, and
33 //! a distinction between a parent that is itself a tag and one that only groups
34 //! (filtering by the latter "would match zero samples", so its label is not
35 //! interactive at all).
36 //!
37 //! None of that is sayable. `RowPart` is `Primary`, `Secondary`, `Meta`,
38 //! `Actions`, `Tokens`, `Proportion` — there is no depth on a row and no member
39 //! that holds rows inside a row. `Node::Region` nests, but a region is a rect
40 //! with its own scroll, not a row with children, and building a tag tree out of
41 //! nested regions would be describing a drawing rather than a hierarchy.
42 //!
43 //! So this port **flattens it**: every tag is one row at its full dotted path,
44 //! togglable as a filter. That is honest about what the filter actually operates
45 //! on — `required_tags` holds exact paths, and the tree is a navigation
46 //! convenience over a flat set — and it loses three real things: the grouping, the
47 //! ability to collapse a branch you are not using, and the parent/leaf
48 //! distinction. On a vault with two hundred tags the described sidebar is a wall
49 //! where the shipped one is an outline.
50 //!
51 //! Filed rather than faked -- `ccaa7e4b`, and it was not: that sentence stood
52 //! here for four days over a task nobody had created. Filed for real 2026-08-21,
53 //! with the flattening above as its measured cost. Note this is not the same gap as
54 //! `Node::Heading { level }`, which says how far down the *document* a title
55 //! sits: that is depth in prose, and this is containment in a set.
56 //!
57 //! # A fourth consumer for the disabled-control precondition
58 //!
59 //! The vault Delete is `danger_button_enabled(ui, "Delete", vfs_count > 1)` with
60 //! `on_disabled_hover_text("Create another vault first, audiofiles needs at
61 //! least one.")`. Same missing fact as `9bab759c`'s other three. The described
62 //! act is disabled and the sentence is said in the section's prose, which is
63 //! wrong in the way that finding predicts.
64 //!
65 //! # What is deliberately not described
66 //!
67 //! - **Renaming a tag or a collection.** The shipped rename opens an inline
68 //! editor *and* computes what it is about to affect — how many samples carry
69 //! the tag, and which descendant tags will not be carried along, because
70 //! `rename_tag_globally` is exact-match-only. That is a flow with a
71 //! consequences screen in it, and it deserves a pass rather than a row in this
72 //! one.
73 //! - **The library picker.** Switching library is `VaultAction::SwitchVault`
74 //! guarded by `has_in_flight_work`, which tears down and rebuilds the whole
75 //! app around a different database. Out of scope for a sidebar region.
76 //! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
77 //! A described first run is its own subject.
78
79 use quasi_router::layout::{Token, Tone};
80 use quasi_router::{
81 Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
82 };
83
84 use super::{Holding, Panels};
85
86 /// The region the sidebar answers into.
87 const SIDE: &str = "library-side";
88
89 /// Register the sidebar's routes.
90 ///
91 /// Every one answers the whole main screen, because the sidebar is a region of
92 /// it and not a place: choosing a vault changes what the list shows, so the
93 /// answer is the window rather than the corner of it that was pressed.
94 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
95 router
96 .post("/vaults/{id}/open", open_vault)
97 .post("/vaults/{id}/delete", delete_vault)
98 .post("/tags/{path}/filter", toggle_tag)
99 .post("/tags/{path}/remove", remove_tag)
100 .post("/collections/{id}/open", open_collection)
101 .post("/collections/close", close_collection)
102 .post("/collections/{id}/delete", delete_collection)
103 }
104
105 /// `POST /vaults/{id}/open`
106 fn open_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
107 let id = numbered(&request, "no such vault")?;
108 // Re-opening the vault you are in is a no-op rather than a refusal, which is
109 // the shipped list's own rule: it would otherwise clear the current
110 // directory, the breadcrumb and the selection, and "the click matches user
111 // expectation" is what that comment says about it.
112 if !state.library.vaults().iter().any(|vault| vault.id == id) {
113 return Err(RouteError::not_found("no such vault"));
114 }
115 state.library.open_vault(id);
116 Ok(super::shell::screen(state).into())
117 }
118
119 /// `POST /vaults/{id}/delete`
120 ///
121 /// Refused where it would leave none, which is the condition the shipped Delete
122 /// is disabled on. A disabled control is an affordance and an address is
123 /// reachable by typing, so the route says it too.
124 fn delete_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
125 let id = numbered(&request, "no such vault")?;
126 if state.library.vaults().len() < 2 {
127 return Err(RouteError::not_found(LAST_VAULT));
128 }
129 state.library.delete_vault(id);
130 Ok(super::shell::screen(state).into())
131 }
132
133 /// `POST /tags/{path}/filter`
134 fn toggle_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
135 let path = request.captures.require("path")?.to_owned();
136 state.library.toggle_tag(&path);
137 Ok(super::shell::screen(state).into())
138 }
139
140 /// `POST /tags/{path}/remove`
141 fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
142 let path = request.captures.require("path")?.to_owned();
143 state.library.remove_tag(&path);
144 Ok(super::shell::screen(state).into())
145 }
146
147 /// `POST /collections/{id}/open`
148 fn open_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
149 let id = numbered(&request, "no such collection")?;
150 state.library.open_collection(id);
151 Ok(super::shell::screen(state).into())
152 }
153
154 /// `POST /collections/close`
155 fn close_collection(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
156 state.library.close_collection();
157 Ok(super::shell::screen(state).into())
158 }
159
160 /// `POST /collections/{id}/delete`
161 fn delete_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
162 let id = numbered(&request, "no such collection")?;
163 state.library.delete_collection(id);
164 Ok(super::shell::screen(state).into())
165 }
166
167 /// The id a request names.
168 fn numbered(request: &Request, whats_wrong: &'static str) -> Result<i64, RouteError> {
169 request
170 .captures
171 .require("id")?
172 .parse()
173 .map_err(|_| RouteError::not_found(whats_wrong))
174 }
175
176 /// What the shipped Delete says when there is only one vault left.
177 const LAST_VAULT: &str = "Create another vault first, audiofiles needs at least one.";
178
179 /// The sidebar, as a region something else holds.
180 pub fn body(state: &Panels<'_>) -> Slot {
181 let side = Slot::new(SIDE, RegionKind::Sidebar);
182 let side = vaults(side, state);
183 let side = collections(side, state);
184 tags(side, state)
185 }
186
187 /// The vaults, and what can be done to one.
188 fn vaults(side: Slot, state: &Panels<'_>) -> Slot {
189 let all = state.library.vaults();
190 let alone = all.len() < 2;
191
192 // The door to the described New Vault modal (see `naming`). It was an
193 // `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
194 // was the one control on this screen whose answer was drawn by hand.
195 let mut side = side
196 .with(Node::section("Vaults"))
197 .with(Node::Act(Act::new("New vault", Action::get("/vaults/new"))));
198
199 let mut rows = Vec::with_capacity(all.len());
200 for vault in &all {
201 let mut delete = Act::new(
202 "Delete",
203 Action::post(format!("/vaults/{}/delete", vault.id)),
204 )
205 .tone(Tone::Danger)
206 // `ConfirmAction::DeleteVfs`, said where the action is. See the
207 // module header.
208 .confirm(format!(
209 "Delete vault \"{}\" and all its contents?",
210 vault.name
211 ));
212 if alone {
213 // Offered dead rather than hidden, which is the shipped menu's
214 // choice: "Always render Delete so the user can see the capability
215 // exists."
216 delete = delete.disabled();
217 }
218
219 // `offers` rather than `act`: the shipped affordance is a right-click
220 // menu, and `Row::menu` is what "held back until the host asks" means.
221 // An inline Delete on every vault row would be a different screen.
222 let mut row = Row::new(&vault.name)
223 .activate(Action::post(format!("/vaults/{}/open", vault.id)))
224 .offers(Act::new(
225 "Rename",
226 Action::get(format!("/vaults/{}/rename", vault.id)),
227 ))
228 .offers(delete);
229 row.current = vault.current;
230 rows.push(row);
231 }
232 side = side.with(Node::List { rows, more: None });
233
234 if alone {
235 // The precondition, said beside the control rather than on it. THE
236 // FINDING, fourth consumer -- see the module header.
237 side = side.with(Node::Text {
238 text: LAST_VAULT.to_owned(),
239 tone: Tone::Info,
240 });
241 }
242 side
243 }
244
245 /// The collections, manual and dynamic.
246 fn collections(side: Slot, state: &Panels<'_>) -> Slot {
247 let all = state.library.collections();
248 let mut side = side.with(Node::section("Collections"));
249
250 if all.is_empty() {
251 return side.with(Node::empty("No collections yet."));
252 }
253
254 let mut rows = Vec::with_capacity(all.len());
255 for collection in &all {
256 // What kind it is, as a token rather than a suffix on the name. The
257 // shipped row appends " (auto)" or " (12)" to the label, with a comment
258 // saying it is a text suffix "instead of a glyph (per the no-emoji brand
259 // rule, and for accessibility)" -- which is right about the glyph and
260 // still puts a second fact inside the name.
261 let mark = match collection.holding {
262 Holding::Dynamic => Tag::badge("auto"),
263 Holding::Fixed(count) => Tag::badge(count.to_string()),
264 };
265 let mut row = Row::new(&collection.name)
266 .token(mark)
267 .activate(if collection.active {
268 Action::post("/collections/close")
269 } else {
270 Action::post(format!("/collections/{}/open", collection.id))
271 })
272 .offers(
273 Act::new(
274 "Delete",
275 Action::post(format!("/collections/{}/delete", collection.id)),
276 )
277 .tone(Tone::Danger)
278 .confirm(format!("Delete collection \"{}\"?", collection.name)),
279 );
280 row.current = collection.active;
281 rows.push(row);
282 }
283
284 side = side.with(Node::List { rows, more: None });
285 side
286 }
287
288 /// The tags, flat.
289 ///
290 /// See the module header: the hierarchy the shipped sidebar draws has no
291 /// description, so what is here is every tag at its full path. The chips latch,
292 /// because a tag filter is on or off and that is exactly what
293 /// [`Token::Chip`]'s `latched` says.
294 fn tags(side: Slot, state: &Panels<'_>) -> Slot {
295 let all = state.library.tags();
296 let mut side = side.with(Node::section("Tags"));
297
298 if all.is_empty() {
299 return side.with(Node::empty("No tags yet."));
300 }
301
302 for filter in &all {
303 side = side.with(Node::Token(Tag {
304 kind: Token::Chip { removable: false },
305 label: filter.path.clone(),
306 tone: if filter.on { Tone::Info } else { Tone::Neutral },
307 latched: filter.on,
308 action: Some(Action::post(format!("/tags/{}/filter", filter.path))),
309 }));
310 }
311
312 // Removing a tag from every sample is not a filter, so it is not a chip. It
313 // is a list of the same tags with a destructive act on each, which is the
314 // shipped right-click menu made visible -- and the second
315 // `ConfirmAction` variant this port replaces.
316 side.with(Node::section("Remove a tag everywhere"))
317 .with(Node::List {
318 rows: all
319 .iter()
320 .map(|filter| {
321 Row::new(&filter.path).offers(
322 Act::new(
323 "Remove",
324 Action::post(format!("/tags/{}/remove", filter.path)),
325 )
326 .tone(Tone::Danger)
327 .confirm(format!(
328 "Remove tag \"{}\" from every sample that has it?",
329 filter.path
330 )),
331 )
332 })
333 .collect(),
334 more: None,
335 })
336 }
337