| 24 |
24 |
|
//! action does**, which is the whole of `524a63fe`'s argument, and the round trip
|
| 25 |
25 |
|
//! through an enum is what a description makes unnecessary rather than shorter.
|
| 26 |
26 |
|
//!
|
| 27 |
|
- |
//! # THE FINDING: a hierarchy of rows has no description
|
|
27 |
+ |
//! # The tag sidebar is an outline
|
| 28 |
28 |
|
//!
|
| 29 |
|
- |
//! audiofiles' tags are dotted — `drums.kick`, `genre.house` — and the shipped
|
|
29 |
+ |
//! audiofiles' tags are dotted -- `drums.kick`, `genre.house` -- and the shipped
|
| 30 |
30 |
|
//! sidebar builds a real tree out of them: `TagNode` with `children`, a
|
| 31 |
31 |
|
//! recursive `draw_tag_node`, a disclosure chevron that is deliberately a
|
| 32 |
32 |
|
//! separate hit target from the label, per-node expansion persisted by egui, and
|
| 34 |
34 |
|
//! (filtering by the latter "would match zero samples", so its label is not
|
| 35 |
35 |
|
//! interactive at all).
|
| 36 |
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.
|
|
37 |
+ |
//! None of that was sayable until `ccaa7e4b`, and this port flattened it: every
|
|
38 |
+ |
//! tag was one row at its full dotted path, which on a vault with two hundred
|
|
39 |
+ |
//! tags is a wall where the shipped one is an outline. `Row::depth` and
|
|
40 |
+ |
//! `Row::open` are what that finding bought, and `tags` says the hierarchy now:
|
|
41 |
+ |
//! a flat list of rows each carrying its indent level, a disclosure on every
|
|
42 |
+ |
//! segment that holds another, and the parent/leaf distinction said in the
|
|
43 |
+ |
//! vocabulary -- a segment that only groups carries no tick, which is the
|
|
44 |
+ |
//! shipped inert label rather than a control that filters to nothing.
|
| 42 |
45 |
|
//!
|
| 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.
|
|
46 |
+ |
//! Two things are deliberately still the renderer's. Which branches are open
|
|
47 |
+ |
//! after the first frame is the reader's, exactly as egui already held it, so
|
|
48 |
+ |
//! nothing here persists it and no route hears about a fold. And the filter
|
|
49 |
+ |
//! itself is a tick rather than a chip: `required_tags` holds exact paths, so
|
|
50 |
+ |
//! ticking a tag is the write and `Row::toggling` is what says so.
|
| 50 |
51 |
|
//!
|
| 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.
|
|
52 |
+ |
//! Not the same distinction as `Node::Heading { level }`, which says how far
|
|
53 |
+ |
//! down the *document* a title sits: that is depth in prose, and this is
|
|
54 |
+ |
//! containment in a set.
|
| 56 |
55 |
|
//!
|
| 57 |
56 |
|
//! # A fourth consumer for the disabled-control precondition
|
| 58 |
57 |
|
//!
|
| 76 |
75 |
|
//! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
|
| 77 |
76 |
|
//! A described first run is its own subject.
|
| 78 |
77 |
|
|
| 79 |
|
- |
use quasi_router::layout::{Token, Tone};
|
|
78 |
+ |
use quasi_router::layout::Tone;
|
| 80 |
79 |
|
use quasi_router::{
|
| 81 |
80 |
|
Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
|
| 82 |
81 |
|
};
|
| 299 |
298 |
|
return side.with(Node::empty("No tags yet."));
|
| 300 |
299 |
|
}
|
| 301 |
300 |
|
|
| 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 |
|
- |
}
|
|
301 |
+ |
// The outline. `ccaa7e4b`: a hierarchy of rows is a flat list of rows each
|
|
302 |
+ |
// carrying its depth, so the dotted paths become an indented list with a
|
|
303 |
+ |
// disclosure on every tag that holds others.
|
|
304 |
+ |
//
|
|
305 |
+ |
// Ticking a tag is the write, which is what `Row::toggling` says: the tick
|
|
306 |
+ |
// is the only affordance for filtering by it, and a button beside a
|
|
307 |
+ |
// checkbox that ignored clicks is what the member exists to stop. A tag
|
|
308 |
+ |
// that only groups is not ticked and not tickable -- filtering by
|
|
309 |
+ |
// `drums` when only `drums.kick` exists matches zero samples, and the
|
|
310 |
+ |
// shipped sidebar refuses it by drawing its label inert. `Row::selected`
|
|
311 |
+ |
// being `None` is that refusal, said in the vocabulary rather than by
|
|
312 |
+ |
// leaving a control out.
|
|
313 |
+ |
side = side.with(Node::List {
|
|
314 |
+ |
rows: outline(&all),
|
|
315 |
+ |
more: None,
|
|
316 |
+ |
});
|
| 311 |
317 |
|
|
| 312 |
|
- |
// Removing a tag from every sample is not a filter, so it is not a chip. It
|
|
318 |
+ |
// Removing a tag from every sample is not a filter, so it is not a tick. It
|
| 313 |
319 |
|
// is a list of the same tags with a destructive act on each, which is the
|
| 314 |
320 |
|
// shipped right-click menu made visible -- and the second
|
| 315 |
321 |
|
// `ConfirmAction` variant this port replaces.
|
|
322 |
+ |
//
|
|
323 |
+ |
// Flat, and it stays flat: what is removed is one exact path, so an outline
|
|
324 |
+ |
// here would group rows under a parent that the act does not act on.
|
| 316 |
325 |
|
side.with(Node::section("Remove a tag everywhere"))
|
| 317 |
326 |
|
.with(Node::List {
|
| 318 |
327 |
|
rows: all
|
| 334 |
343 |
|
more: None,
|
| 335 |
344 |
|
})
|
| 336 |
345 |
|
}
|
|
346 |
+ |
|
|
347 |
+ |
/// The tags as an outline: one row per segment, in path order.
|
|
348 |
+ |
///
|
|
349 |
+ |
/// A parent appears whether or not it is a tag in its own right, because
|
|
350 |
+ |
/// `drums.kick` with no `drums` beside it is still a `drums` the reader can
|
|
351 |
+ |
/// fold. What separates the two is what the row offers: a real tag is ticked to
|
|
352 |
+ |
/// filter by it, and a parent that only groups offers the chevron and nothing
|
|
353 |
+ |
/// else.
|
|
354 |
+ |
///
|
|
355 |
+ |
/// Every branch starts open, which is the shipped sidebar's own first frame.
|
|
356 |
+ |
/// Where it goes from there is the renderer's, and the reader's -- see
|
|
357 |
+ |
/// `Row::open`.
|
|
358 |
+ |
fn outline(all: &[super::Filter]) -> Vec<Row> {
|
|
359 |
+ |
// Sorted, so a segment's children follow it. The library answers in its own
|
|
360 |
+ |
// order and the outline is only an outline if `drums.kick` comes after
|
|
361 |
+ |
// `drums`.
|
|
362 |
+ |
let mut paths: Vec<&str> = all.iter().map(|filter| filter.path.as_str()).collect();
|
|
363 |
+ |
paths.sort_unstable();
|
|
364 |
+ |
|
|
365 |
+ |
let mut rows: Vec<Row> = Vec::with_capacity(paths.len());
|
|
366 |
+ |
let mut said: Vec<String> = Vec::new();
|
|
367 |
+ |
for path in paths {
|
|
368 |
+ |
for (depth, _) in path
|
|
369 |
+ |
.match_indices('.')
|
|
370 |
+ |
.chain(std::iter::once((path.len(), "")))
|
|
371 |
+ |
{
|
|
372 |
+ |
let branch = &path[..depth];
|
|
373 |
+ |
if said.iter().any(|seen| seen == branch) {
|
|
374 |
+ |
continue;
|
|
375 |
+ |
}
|
|
376 |
+ |
said.push(branch.to_owned());
|
|
377 |
+ |
let level = branch.matches('.').count();
|
|
378 |
+ |
let label = branch.rsplit('.').next().unwrap_or(branch);
|
|
379 |
+ |
let mut row = Row::new(label).depth(level);
|
|
380 |
+ |
// A tag in its own right is ticked to filter by it. A segment that
|
|
381 |
+ |
// only groups is neither ticked nor tickable.
|
|
382 |
+ |
if let Some(filter) = all.iter().find(|filter| filter.path == branch) {
|
|
383 |
+ |
row = row.toggling(
|
|
384 |
+ |
filter.on,
|
|
385 |
+ |
Action::post(format!("/tags/{}/filter", filter.path)),
|
|
386 |
+ |
);
|
|
387 |
+ |
}
|
|
388 |
+ |
// A disclosure on anything that holds something, and nothing on a
|
|
389 |
+ |
// leaf: a chevron over an empty branch is an affordance that opens
|
|
390 |
+ |
// onto nothing.
|
|
391 |
+ |
let prefix = format!("{branch}.");
|
|
392 |
+ |
if all.iter().any(|filter| filter.path.starts_with(&prefix)) {
|
|
393 |
+ |
row = row.disclosing(true);
|
|
394 |
+ |
}
|
|
395 |
+ |
rows.push(row);
|
|
396 |
+ |
}
|
|
397 |
+ |
}
|
|
398 |
+ |
rows
|
|
399 |
+ |
}
|