Skip to main content

max / audiofiles

Describe the detail panel: one sample, several, or none The fifth audiofiles screen through quasi, and the first whose subject is the selection rather than an address. One route answers three screens, which is the shape sync found for a state machine and export found for a flow. What the description deletes: three hand-written matches over Option<Result<V, ()>> that each collapsed "they disagree" and "none of them has it" onto one string. Shared says which it is, so a renderer can draw them differently. Tag provenance loses its second collapsed section repeating every tag, because a token carries a tone and the tone is what that section was colouring. Every write here is an intent, including the two the backend would take directly. Removing a tag pushes an undo entry, sets the status line and re-reads the selection, all &mut BrowserState -- so a route calling Backend::remove_tag would remove the tag and silently lose the undo. The rule that falls out: what the app does about a write decides where the write goes. Not described, deliberately: the waveform (a canvas answering a pointer), the Tab-from-table focus handoff, the collapsing sections. Two findings filed as quasicoherent problems, both second consumers: - an Act that is disabled cannot say what would make it available, which is makeover-layout e761833e arriving on a different member - handing text to the clipboard is a host act with no Step, so this port routes it through an intent, which is the wrong layer 19 tests, 428 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 19:10 UTC
Signed with PGP, not checked
Commit: 31e048e1d9c3ad44ad663b8f2d10cb18243be588
Parent: 990e929
7 files changed, +1580 insertions, -26 deletions
M Cargo.lock +12 -12
@@ -7543,6 +7543,18 @@
7543 7543 "winnow 1.0.4",
7544 7544 ]
7545 7545
7546 + [[patch.unused]]
7547 + name = "kberg"
7548 + version = "0.1.0"
7549 +
7550 + [[patch.unused]]
7551 + name = "ops-status"
7552 + version = "0.1.0"
7553 +
7554 + [[patch.unused]]
7555 + name = "painhours"
7556 + version = "0.1.0"
7557 +
7546 7558 [[patch.unused]]
7547 7559 name = "quasi-axum"
7548 7560 version = "0.14.0"
@@ -7566,15 +7578,3 @@
7566 7578 [[patch.unused]]
7567 7579 name = "quasi-webview"
7568 7580 version = "0.14.0"
7569 -
7570 - [[patch.unused]]
7571 - name = "kberg"
7572 - version = "0.1.0"
7573 -
7574 - [[patch.unused]]
7575 - name = "ops-status"
7576 - version = "0.1.0"
7577 -
7578 - [[patch.unused]]
7579 - name = "painhours"
7580 - version = "0.1.0"
@@ -175,6 +175,10 @@
175 175 #[cfg(feature = "quasi")]
176 176 {
177 177 state.described.show_files = true;
178 + // The detail panel is the same case for the same reason: the
179 + // shipped one is a pane in the main window, so there is no toggle
180 + // to share and Settings opens it too.
181 + state.described.show_detail = true;
178 182 }
179 183 }
180 184
@@ -183,6 +187,11 @@
183 187 crate::quasi::panel::draw_files(ctx, state);
184 188 }
185 189
190 + #[cfg(feature = "quasi")]
191 + if state.described.show_detail {
192 + crate::quasi::panel::draw_detail(ctx, state);
193 + }
194 +
186 195 // The described export flow, beside whichever of the shipped export screens
187 196 // is showing. On the flow's own state rather than on a toggle, because the
188 197 // shipped side is not a window either: it takes over the central pane, and
@@ -20,6 +20,7 @@
20 20 //! | [`Sync`] | [`sync`] | the sync manager's own `&self` methods |
21 21 //! | [`Files`] | [`files`] | an [`Intent`], applied after the frame |
22 22 //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame |
23 + //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame |
23 24 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
24 25 //!
25 26 //! The themes are the settled rule from goingson's settings port applied first
@@ -28,18 +29,29 @@
28 29 //! quasi — was refused on 2026-08-09 and nothing here reopens it.
29 30 //!
30 31 //! Notably absent is anything `&mut`, and the right-hand column is why it can
31 - //! be. Two of the four write through a handle that already takes `&self`; the
32 - //! other two write to the app's own UI state, which a route cannot hold, so they
33 - //! record an [`Intent`] and the panel applies it with the `&mut` the app has
34 - //! anyway. See [`files`]'s header for the rule and [`export`]'s for what it
32 + //! be. Two of the five write through a handle that already takes `&self`; the
33 + //! other three write to the app's own UI state, which a route cannot hold, so
34 + //! they record an [`Intent`] and the panel applies it with the `&mut` the app
35 + //! has anyway. See [`files`]'s header for the rule and [`export`]'s for what it
35 36 //! costs — an intent lands after the answer was built, which is what
36 37 //! `Runtime::reload` exists to correct.
38 + //!
39 + //! [`Detail`] sharpened that rule rather than following it. Two of its writes —
40 + //! adding a tag, removing one — go to the app's *data* through a `&self` method
41 + //! the backend already has, so by the sentence above they should have been
42 + //! handle calls. They are intents, because what the app does *around* the write
43 + //! is `&mut`: removing a tag pushes an undo entry the description could not
44 + //! have pushed, and a route calling the backend directly would have removed the
45 + //! tag and silently lost Cmd+Z. So the rule is not "reads through a handle,
46 + //! writes through an intent" but **what the app does about a write decides
47 + //! where the write goes**. [`Detail`]'s header has the long form.
37 48
38 49 // Handlers take their request by value because `quasi_router::Handler` is a
39 50 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
40 51 // a choice made here.
41 52 #![allow(clippy::needless_pass_by_value)]
42 53
54 + pub mod detail;
43 55 pub mod export;
44 56 pub mod files;
45 57 pub mod panel;
@@ -484,6 +496,30 @@
484 496 CancelExport,
485 497 /// Put the flow away.
486 498 DismissExport,
499 + /// Tag the sample in focus.
500 + AddTag(String),
501 + /// Untag the sample in focus.
502 + RemoveTag(String),
503 + /// Go and look for tags on similar samples.
504 + Suggest,
505 + /// Take one of those suggestions.
506 + AcceptSuggestion(String),
507 + /// Put the sample's path on the clipboard.
508 + CopyPath,
509 + /// Open the sample editor.
510 + Edit,
511 + /// Open the forge.
512 + Forge,
513 + /// Look for samples that sound like this one.
514 + FindSimilar,
515 + /// Look for near-duplicates of this one.
516 + FindDuplicates,
517 + /// Tag every chosen sample that lacks this tag.
518 + SpreadTag(String),
519 + /// Untag every chosen sample that carries this tag.
520 + StripTag(String),
521 + /// Open the bulk tag editor.
522 + EditSelection,
487 523 }
488 524
489 525 /// The app's file list, as the narrow thing a described screen borrows.
@@ -902,6 +938,467 @@
902 938 }
903 939 }
904 940
941 + /// What the detail panel is about.
942 + ///
943 + /// The panel's subject is the selection, and a selection is not an address: a
944 + /// user does not navigate to "three samples are chosen", they arrive there by
945 + /// choosing three. So this is [`Phase`]'s shape for a different reason than
946 + /// [`Phase`] has it — one route answering three screens, because the state is
947 + /// something that happened rather than somewhere to go. `sync`'s four states
948 + /// settled that pattern and this is the third screen to take it.
949 + #[derive(Debug, Clone, PartialEq)]
950 + pub enum Focus {
951 + /// Nothing is chosen, or what is chosen is the parent entry.
952 + Nothing,
953 + /// One sample, with everything known about it.
954 + One(Box<Detailed>),
955 + /// Several, so what is describable is what they have in common.
956 + Several(Box<Spread>),
957 + }
958 +
959 + /// One sample, as the detail screen needs to name it.
960 + ///
961 + /// [`Sample`]'s peer, separate from it for the reason [`Subject`] is separate
962 + /// from both: the file list needs a row, the export needs the rename context,
963 + /// and this needs everything analysis found. One shared type would put every
964 + /// field any screen wants in all three.
965 + #[derive(Debug, Clone, PartialEq)]
966 + pub struct Detailed {
967 + /// The row's own id, which is what its addresses are built from.
968 + pub id: i64,
969 + /// What it is called.
970 + pub name: String,
971 + /// Where it sits, as the host spells the path.
972 + pub path: Option<String>,
973 + /// What analysis found, where it has run.
974 + pub analysis: Option<Analysis>,
975 + /// What it is tagged with, and where each tag came from.
976 + pub tags: Vec<Tagged>,
977 + /// Tags found on acoustically similar samples, once asked for.
978 + pub suggestions: Vec<Suggested>,
979 + /// Whether it is a sample rather than a folder, which is what the editing
980 + /// controls need: the shipped panel offers Edit and Forge only where there
981 + /// is a hash to open them on.
982 + pub is_sample: bool,
983 + /// Whether the spectral features Find Similar reads were computed.
984 + pub has_spectral: bool,
985 + /// Whether the fingerprint Find Duplicates reads was computed.
986 + pub has_fingerprint: bool,
987 + }
988 +
989 + /// What analysis found, as the description names it.
990 + ///
991 + /// The nine fields the panel shows, out of `AnalysisResult`'s twenty-two. The
992 + /// rest — the feature vector, the fingerprint bytes, the spectral moments — are
993 + /// inputs to the two discovery paths rather than facts a reader is shown, and
994 + /// they reach this screen as [`Detailed::has_spectral`] and
995 + /// [`Detailed::has_fingerprint`], which is the only thing it says about them.
996 + #[derive(Debug, Clone, PartialEq)]
997 + pub struct Analysis {
998 + /// How long it runs, in seconds.
999 + pub duration: f64,
1000 + /// Frames per second.
1001 + pub sample_rate: u32,
1002 + /// How many channels.
1003 + pub channels: u16,
1004 + /// Beats per minute, where one was found.
1005 + pub bpm: Option<f64>,
1006 + /// The musical key, where one was found.
1007 + pub musical_key: Option<String>,
1008 + /// Peak level in dBFS.
1009 + pub peak_db: Option<f64>,
1010 + /// RMS level in dBFS.
1011 + pub rms_db: Option<f64>,
1012 + /// Integrated loudness.
1013 + pub lufs: Option<f64>,
1014 + /// Whether it loops cleanly.
1015 + pub is_loop: Option<bool>,
1016 + }
1017 +
1018 + /// One tag on one sample, and where it came from.
1019 + #[derive(Debug, Clone, PartialEq, Eq)]
1020 + pub struct Tagged {
1021 + /// The tag itself.
1022 + pub name: String,
1023 + /// Who put it there.
1024 + pub source: Source,
1025 + }
1026 +
1027 + /// Who put a tag on a sample.
1028 + ///
1029 + /// Mirrored as an enum where the app holds a string, which is the one place this
1030 + /// port narrows rather than copies: the store's `source` column is open text and
1031 + /// the panel already switches on four known values, so a described screen that
1032 + /// carried the string would make every renderer repeat that switch.
1033 + /// [`Source::Other`] keeps whatever the store said, so a source the app grows
1034 + /// still reaches the reader rather than being flattened to "manual".
1035 + #[derive(Debug, Clone, PartialEq, Eq)]
1036 + pub enum Source {
1037 + /// Typed in by hand, which is what an unrecorded source means.
1038 + Manual,
1039 + /// A tagging rule matched.
1040 + Rule,
1041 + /// The classifier proposed it and it was accepted.
1042 + Suggested,
1043 + /// It came out of a cluster.
1044 + Cluster,
1045 + /// Harvested from the folder the file was in.
1046 + Folder,
1047 + /// Something the app has grown since this list was written.
1048 + Other(String),
1049 + }
1050 +
1051 + impl Source {
1052 + /// What the panel calls it.
1053 + #[must_use]
1054 + pub fn as_str(&self) -> &str {
1055 + match self {
1056 + Self::Manual => "manual",
1057 + Self::Rule => "rule",
1058 + Self::Suggested => "suggested",
1059 + Self::Cluster => "cluster",
1060 + Self::Folder => "folder",
1061 + Self::Other(other) => other,
1062 + }
1063 + }
1064 +
1065 + /// The source that name means.
1066 + fn of(name: Option<&str>) -> Self {
1067 + match name {
1068 + None => Self::Manual,
1069 + Some("rule") => Self::Rule,
1070 + Some("ml") => Self::Suggested,
1071 + Some("cluster") => Self::Cluster,
1072 + Some("harvest") => Self::Folder,
1073 + Some(other) => Self::Other(other.to_owned()),
1074 + }
1075 + }
1076 + }
1077 +
1078 + /// A tag some similar sample carries, offered for this one.
1079 + #[derive(Debug, Clone, PartialEq)]
1080 + pub struct Suggested {
1081 + /// The tag.
1082 + pub tag: String,
1083 + /// How confident the classifier is, from zero to one.
1084 + pub score: f64,
1085 + /// How many similar samples carry it.
1086 + pub neighbours: usize,
1087 + }
1088 +
1089 + /// Several samples at once, as the description can name them.
1090 + ///
1091 + /// What a multi-selection has to say is what its members agree on, so every
1092 + /// field here is already reduced. The reduction is the app's
1093 + /// (`ui::detail::summarize`) and stays there: whether three samples share a
1094 + /// tempo is a fact about them rather than a rendering decision, and a
1095 + /// description that carried three tempos would make each renderer decide again
1096 + /// what to do when they disagree.
1097 + #[derive(Debug, Clone, PartialEq, Eq)]
1098 + pub struct Spread {
1099 + /// How many samples are chosen.
1100 + pub samples: usize,
1101 + /// How many folders are chosen alongside them.
1102 + pub folders: usize,
1103 + /// The tempo they share, if they share one.
1104 + pub bpm: Shared,
1105 + /// The key they share, if they share one.
1106 + pub musical_key: Shared,
1107 + /// The length they share, if they share one.
1108 + pub duration: Shared,
1109 + /// Every tag any of them carries, and how many carry it.
1110 + pub tags: Vec<Coverage>,
1111 + }
1112 +
1113 + /// One field across a selection.
1114 + ///
1115 + /// Three answers rather than `Option<Option<T>>`, which is what the app's own
1116 + /// `summarize` returns and is unreadable at the call site: `Some(Err(()))` is
1117 + /// "they disagree" and nothing in the type says so.
1118 + #[derive(Debug, Clone, PartialEq, Eq)]
1119 + pub enum Shared {
1120 + /// Every one of them says this.
1121 + Same(String),
1122 + /// They do not agree.
1123 + Varies,
1124 + /// None of them has it at all.
1125 + Absent,
1126 + }
1127 +
1128 + /// One tag across a selection.
1129 + #[derive(Debug, Clone, PartialEq, Eq)]
1130 + pub struct Coverage {
1131 + /// The tag.
1132 + pub name: String,
1133 + /// How many of the chosen samples carry it.
1134 + pub on: usize,
1135 + }
1136 +
1137 + /// The detail panel, as much of it as a described screen needs.
1138 + ///
1139 + /// The fifth narrow trait, and the first whose **writes are all intents**. Every
1140 + /// port before it had at least one write that went through a handle the app
1141 + /// already had; here even the two that look like plain data writes — adding a
1142 + /// tag, removing one — are recorded instead, and the reason is worth stating as
1143 + /// the rule the next port will want:
1144 + ///
1145 + /// **A data write whose consequences are UI state is an intent, not a handle
1146 + /// call.** `Backend::add_tag` is `&self` and a route could call it. What the
1147 + /// shipped panel does around that call is not: removing a tag pushes an undo
1148 + /// entry, sets the status line and re-reads `detail.selected_tags`, all
1149 + /// `&mut BrowserState`. A described screen that called the backend directly
1150 + /// would write the tag and lose the undo, which is a worse outcome than not
1151 + /// describing the control — it would look like it worked.
1152 + ///
1153 + /// So the boundary is not "reads through a handle, writes through an intent". It
1154 + /// is: **what the app does about a write decides where the write goes.** See
1155 + /// [`files`]'s header for the first half of this rule and [`export`]'s for what
1156 + /// an intent costs.
1157 + pub trait Detail {
1158 + /// What the panel is about.
1159 + fn focus(&self) -> Focus;
1160 +
1161 + /// Put this tag on the sample in focus.
1162 + fn add_tag(&self, tag: &str);
1163 +
1164 + /// Take this tag off the sample in focus.
1165 + fn remove_tag(&self, tag: &str);
1166 +
1167 + /// Go and find tags from acoustically similar samples.
1168 + fn suggest(&self);
1169 +
1170 + /// Take one of the suggestions.
1171 + fn accept(&self, tag: &str);
1172 +
1173 + /// Put the sample's path on the clipboard.
1174 + fn copy_path(&self);
1175 +
1176 + /// Open the sample editor.
1177 + fn edit(&self);
1178 +
1179 + /// Open the forge.
1180 + fn forge(&self);
1181 +
1182 + /// Find samples that sound like this one.
1183 + fn find_similar(&self);
1184 +
1185 + /// Find near-duplicates of this one.
1186 + fn find_duplicates(&self);
1187 +
1188 + /// Put this tag on every chosen sample that lacks it.
1189 + fn spread_tag(&self, tag: &str);
1190 +
1191 + /// Take this tag off every chosen sample that carries it.
1192 + fn strip_tag(&self, tag: &str);
1193 +
1194 + /// Open the bulk tag editor over the whole selection.
1195 + fn edit_selection(&self);
1196 + }
1197 +
1198 + /// The app's detail panel, as the narrow thing a described screen borrows.
1199 + ///
1200 + /// Reads come off `BrowserState` and the analysis the app has already loaded
1201 + /// into `detail.selected_analysis`; every write is recorded. See [`Detail`]'s
1202 + /// header for why even the tag writes are recorded when the backend would take
1203 + /// them directly.
1204 + pub struct FromSelection<'a> {
1205 + /// What the app has selected and loaded already.
1206 + pub state: &'a crate::state::BrowserState,
1207 + /// What the described screen asked for, applied after the frame.
1208 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1209 + }
1210 +
1211 + impl Detail for FromSelection<'_> {
1212 + fn focus(&self) -> Focus {
1213 + if self.state.nav.selection.count() > 1 {
1214 + return Focus::Several(Box::new(self.spread()));
1215 + }
1216 + let Some(node) = self.state.selected_node() else {
1217 + return Focus::Nothing;
1218 + };
1219 + Focus::One(Box::new(Detailed {
1220 + id: node.node.id.as_i64(),
1221 + name: node.node.name.clone(),
1222 + path: self.state.selected_sample_path(),
1223 + analysis: self
1224 + .state
1225 + .detail
1226 + .selected_analysis
1227 + .as_ref()
1228 + .map(|found| Analysis {
1229 + duration: found.duration,
1230 + sample_rate: found.sample_rate,
1231 + channels: found.channels,
1232 + bpm: found.bpm,
1233 + musical_key: found.musical_key.clone(),
1234 + peak_db: found.peak_db,
1235 + rms_db: found.rms_db,
1236 + lufs: found.lufs,
1237 + is_loop: found.is_loop,
1238 + }),
1239 + tags: self
1240 + .state
1241 + .detail
1242 + .selected_tags
1243 + .iter()
1244 + .map(|tag| Tagged {
1245 + name: tag.clone(),
1246 + source: Source::of(
1247 + self.state
1248 + .detail
1249 + .selected_tag_sources
1250 + .get(tag)
1251 + .map(|(source, _)| source.as_str()),
1252 + ),
1253 + })
1254 + .collect(),
1255 + suggestions: self
1256 + .state
1257 + .detail
1258 + .selected_ml_suggestions
1259 + .iter()
1260 + .map(|found| Suggested {
1261 + tag: found.tag.clone(),
1262 + score: found.score,
1263 + neighbours: found.neighbors.len(),
1264 + })
1265 + .collect(),
1266 + is_sample: node.node.sample_hash.is_some(),
1267 + has_spectral: self
1268 + .state
1269 + .detail
1270 + .selected_analysis
1271 + .as_ref()
1272 + .is_some_and(|found| {
1273 + found.spectral_centroid.is_some() || found.spectral_bandwidth.is_some()
1274 + }),
1275 + has_fingerprint: self
1276 + .state
1277 + .detail
1278 + .selected_analysis
1279 + .as_ref()
1280 + .is_some_and(|found| found.fingerprint.is_some()),
1281 + }))
1282 + }
1283 +
1284 + fn add_tag(&self, tag: &str) {
1285 + self.push(Intent::AddTag(tag.to_owned()));
1286 + }
1287 +
1288 + fn remove_tag(&self, tag: &str) {
1289 + self.push(Intent::RemoveTag(tag.to_owned()));
1290 + }
1291 +
1292 + fn suggest(&self) {
1293 + self.push(Intent::Suggest);
1294 + }
1295 +
1296 + fn accept(&self, tag: &str) {
1297 + self.push(Intent::AcceptSuggestion(tag.to_owned()));
1298 + }
1299 +
1300 + fn copy_path(&self) {
1301 + self.push(Intent::CopyPath);
1302 + }
1303 +
1304 + fn edit(&self) {
1305 + self.push(Intent::Edit);
1306 + }
1307 +
1308 + fn forge(&self) {
1309 + self.push(Intent::Forge);
1310 + }
1311 +
1312 + fn find_similar(&self) {
1313 + self.push(Intent::FindSimilar);
1314 + }
1315 +
1316 + fn find_duplicates(&self) {
1317 + self.push(Intent::FindDuplicates);
1318 + }
1319 +
1320 + fn spread_tag(&self, tag: &str) {
1321 + self.push(Intent::SpreadTag(tag.to_owned()));
1322 + }
1323 +
1324 + fn strip_tag(&self, tag: &str) {
1325 + self.push(Intent::StripTag(tag.to_owned()));
1326 + }
1327 +
1328 + fn edit_selection(&self) {
1329 + self.push(Intent::EditSelection);
1330 + }
1331 + }
1332 +
1333 + impl FromSelection<'_> {
1334 + /// Record what the described screen asked for.
1335 + fn push(&self, intent: Intent) {
1336 + self.intents.borrow_mut().push(intent);
1337 + }
1338 +
1339 + /// What the chosen samples have in common.
1340 + ///
1341 + /// The reduction the shipped panel does, called through the app's own
1342 + /// helpers rather than repeated here: `selected_nodes` is what the panel
1343 + /// reads and the agreement test is `ui::detail::summarize`'s, made public
1344 + /// for this so the two cannot drift.
1345 + fn spread(&self) -> Spread {
1346 + let nodes = self.state.selected_nodes();
1347 + let samples: Vec<_> = nodes
1348 + .iter()
1349 + .filter(|node| node.node.sample_hash.is_some())
1350 + .collect();
1351 + let count = samples.len();
1352 +
1353 + let mut counts: std::collections::BTreeMap<String, usize> =
1354 + std::collections::BTreeMap::new();
1355 + for node in &samples {
1356 + for tag in &node.tags {
1357 + *counts.entry(tag.clone()).or_insert(0) += 1;
1358 + }
1359 + }
1360 + let mut tags: Vec<Coverage> = counts
1361 + .into_iter()
1362 + .map(|(name, on)| Coverage { name, on })
1363 + .collect();
1364 + // Widest coverage first, then alphabetical, which is the order the
1365 + // shipped panel sorts its badges into.
1366 + tags.sort_by(|left, right| {
1367 + right
Lines truncated
@@ -33,8 +33,8 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromContents, FromExport, FromSyncManager, Intent, Panels, Setting, Sync,
37 - ThemeChoice, Unconfigured,
36 + FromBackend, FromContents, FromExport, FromSelection, FromSyncManager, Intent, Panels, Setting,
37 + Sync, ThemeChoice, Unconfigured,
38 38 };
39 39 use crate::state::BrowserState;
40 40 use crate::ui::theme;
@@ -50,6 +50,13 @@
50 50 sync: Option<Runtime>,
51 51 files: Option<Runtime>,
52 52 export: Option<Runtime>,
53 + detail: Option<Runtime>,
54 + /// Whether the described detail panel is open.
55 + ///
56 + /// [`show_files`](Self::show_files)'s twin and for the same reason: the
57 + /// shipped detail panel is a pane inside the main window rather than a
58 + /// window with a toggle of its own, so there is nothing to share.
59 + pub show_detail: bool,
53 60 /// Whether the described file list is open.
54 61 ///
55 62 /// Its own flag rather than the shipped list's, because the shipped list is
@@ -92,7 +99,7 @@
92 99 stale,
93 100 );
94 101 state.described.settings = runtime;
95 - apply(state, intents.into_inner());
102 + apply(ctx, state, intents.into_inner());
96 103 if closed {
97 104 state.settings.show_manager = false;
98 105 state.described.settings = None;
@@ -123,7 +130,7 @@
123 130 stale,
124 131 );
125 132 state.described.sync = runtime;
126 - apply(state, intents.into_inner());
133 + apply(ctx, state, intents.into_inner());
127 134 if closed {
128 135 state.sync.show_panel = false;
129 136 state.described.sync = None;
@@ -150,7 +157,7 @@
150 157 stale,
151 158 );
152 159 state.described.files = runtime;
153 - apply(state, intents.into_inner());
160 + apply(ctx, state, intents.into_inner());
154 161 if closed {
155 162 state.described.show_files = false;
156 163 state.described.files = None;
@@ -183,12 +190,45 @@
183 190 true,
184 191 );
185 192 state.described.export = runtime;
186 - apply(state, intents.into_inner());
193 + apply(ctx, state, intents.into_inner());
187 194 if closed {
188 195 state.described.export = None;
189 196 }
190 197 }
191 198
199 + /// Draw the described detail panel, and act on whatever was pressed.
200 + ///
201 + /// **Refreshed unconditionally**, like the export flow and for a related reason:
202 + /// its subject is the selection, which the *shipped* file list changes. Every
203 + /// other described window moves only when something inside it was pressed; this
204 + /// one moves when the user clicks a row in a pane it does not know about, and
205 + /// there is no intent to hang a refresh on because nothing described was
206 + /// touched.
207 + pub fn draw_detail(ctx: &egui::Context, state: &mut BrowserState) {
208 + let intents = RefCell::new(Vec::new());
209 + let mut runtime = state.described.detail.take();
210 + let host = Host {
211 + state,
212 + sync: None,
213 + themes: themes(),
214 + intents: &intents,
215 + };
216 + let closed = window(
217 + ctx,
218 + "Detail (described)",
219 + &mut runtime,
220 + &host,
221 + "/detail",
222 + true,
223 + );
224 + state.described.detail = runtime;
225 + apply(ctx, state, intents.into_inner());
226 + if closed {
227 + state.described.show_detail = false;
228 + state.described.detail = None;
229 + }
230 + }
231 +
192 232 /// Do what a described screen asked the app to do to itself.
193 233 ///
194 234 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -199,7 +239,7 @@
199 239 /// Each arm calls what the shipped list calls, rather than reaching into the
200 240 /// fields itself: a described screen that set `nav.selection` by hand would be a
201 241 /// second implementation of selection, which is what the port is for avoiding.
202 - fn apply(state: &mut BrowserState, intents: Vec<Intent>) {
242 + fn apply(ctx: &egui::Context, state: &mut BrowserState, intents: Vec<Intent>) {
203 243 // Anything applied here landed *after* the router answered, so the screen
204 244 // showing was built without it. The next frame reloads.
205 245 state.described.stale = !intents.is_empty();
@@ -247,10 +287,117 @@
247 287 };
248 288 state.toggle_sort(key);
249 289 }
290 + Intent::AddTag(tag) => add_tag(state, &tag),
291 + Intent::RemoveTag(tag) => remove_tag(state, &tag),
292 + Intent::Suggest => state.suggest_ml_for_selected(),
293 + Intent::AcceptSuggestion(tag) => state.accept_ml_suggestion(&tag),
294 + // The one intent the app does not perform on itself. See
295 + // `detail`'s header: a clipboard is the system's and the
296 + // description has no way to say so, so it arrives here as an
297 + // ordinary intent and the host does what only a host can.
298 + Intent::CopyPath => {
299 + if let Some(path) = state.selected_sample_path() {
300 + state.status = format!("Copied: {path}");
301 + ctx.copy_text(path);
302 + }
303 + }
304 + Intent::Edit => {
305 + if let Some(hash) = selected_hash(state) {
306 + state.open_edit_window(&hash);
307 + }
308 + }
309 + Intent::Forge => {
310 + if let Some(hash) = selected_hash(state) {
311 + state.open_forge_window(&hash);
312 + }
313 + }
314 + Intent::FindSimilar => {
315 + if let Some(hash) = selected_hash(state) {
316 + state.find_similar(&hash);
317 + }
318 + }
319 + Intent::FindDuplicates => {
320 + if let Some(hash) = selected_hash(state) {
321 + state.find_near_duplicates(&hash);
322 + }
323 + }
324 + Intent::SpreadTag(tag) => {
325 + let targets = across(state, |node| !node.tags.contains(&tag));
326 + state.apply_tag_to_hashes(&tag, &targets);
327 + }
328 + Intent::StripTag(tag) => {
329 + let targets = across(state, |node| node.tags.contains(&tag));
330 + state.remove_tag_from_hashes(&tag, &targets);
331 + }
332 + Intent::EditSelection => state.open_bulk_tag_modal(),
250 333 }
251 334 }
252 335 }
253 336
337 + /// Put a tag on the selected sample, the way the shipped panel does.
338 + ///
339 + /// Validated here rather than in the route, because validation is the app's:
340 + /// `audiofiles_core::tags::validate_tag` is what the shipped panel calls and a
341 + /// described screen that carried a second copy of the rule would be a second
342 + /// implementation of what a tag may be.
343 + fn add_tag(state: &mut BrowserState, tag: &str) {
344 + let Some(hash) = selected_hash(state) else {
345 + return;
346 + };
347 + if audiofiles_core::tags::validate_tag(tag).is_err() {
348 + state.status = format!("Invalid tag: {tag}");
349 + return;
350 + }
351 + let _ = state.backend.add_tag(&hash, tag);
352 + state.detail.tag_input.clear();
353 + state.refresh_selected_tags();
354 + }
355 +
356 + /// Take a tag off the selected sample, undo entry and all.
357 + ///
358 + /// **The undo is why this is here and not in the route.** `Backend::remove_tag`
359 + /// is `&self` and a handler could call it; what it could not do is push the
360 + /// `UndoOp::TagRemove` that makes Cmd+Z put the tag back, because that is
361 + /// `&mut BrowserState`. A described screen that called the backend directly
362 + /// would remove the tag and silently lose the undo. See `Detail`'s header.
363 + fn remove_tag(state: &mut BrowserState, tag: &str) {
364 + let Some(hash) = selected_hash(state) else {
365 + return;
366 + };
367 + if state.backend.remove_tag(&hash, tag).is_ok() {
368 + state.push_undo(crate::state::UndoOp::TagRemove {
369 + hash: hash.clone(),
370 + tag: tag.to_owned(),
371 + });
372 + state.status = format!("Removed tag \"{tag}\"");
373 + state.refresh_selected_tags();
374 + }
375 + }
376 +
377 + /// The hash of whatever is selected, where it is a sample.
378 + fn selected_hash(state: &BrowserState) -> Option<String> {
379 + state
380 + .selected_node()
381 + .and_then(|node| node.node.sample_hash.as_ref().map(ToString::to_string))
382 + }
383 +
384 + /// The chosen samples this tag operation applies to.
385 + ///
386 + /// The filtering is the shipped panel's: applying a tag touches only the samples
387 + /// that lack it and removing one touches only those that carry it, so the counts
388 + /// the described row shows are the counts the operation acts on.
389 + fn across(
390 + state: &BrowserState,
391 + wanted: impl Fn(&audiofiles_core::vfs::VfsNodeWithAnalysis) -> bool,
392 + ) -> Vec<String> {
393 + state
394 + .selected_nodes()
395 + .into_iter()
396 + .filter(|node| node.node.sample_hash.is_some() && wanted(node))
397 + .filter_map(|node| node.node.sample_hash.as_ref().map(ToString::to_string))
398 + .collect()
399 + }
400 +
254 401 /// Write one described setting back into the app's own export config.
255 402 ///
256 403 /// The described value is a string because that is what a control submits, and
@@ -448,11 +595,13 @@
448 595
449 596 let files = FromContents { state, intents };
450 597 let export = FromExport { state, intents };
598 + let detail = FromSelection { state, intents };
451 599 let panels = Panels {
452 600 config: &config,
453 601 sync,
454 602 files: &files,
455 603 export: &export,
604 + detail: &detail,
456 605 themes,
457 606 };
458 607 let response = super::router()
@@ -12,8 +12,9 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Channels, ColumnsShown, Config, Export, Files, Format, Panels, Phase, Pricing, ProfileChoice,
16 - Sample, Setting, Settings, State, Status, Subject, Subscription, Sync, ThemeChoice, router,
15 + Analysis, Channels, ColumnsShown, Config, Coverage, Detail, Detailed, Export, Files, Focus,
16 + Format, Panels, Phase, Pricing, ProfileChoice, Sample, Setting, Settings, Shared, Source,
17 + Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, router,
17 18 };
18 19
19 20 /// A config store in memory.
@@ -206,6 +207,7 @@
206 207 let files = FakeFiles::default();
207 208 let themes = themes();
208 209 let state = Panels {
210 + detail: &Unfocused,
209 211 config: &store,
210 212 sync: &sync,
211 213 files: &files,
@@ -262,6 +264,7 @@
262 264 let sync = Offline;
263 265 let themes = themes();
264 266 let state = Panels {
267 + detail: &Unfocused,
265 268 config: &store,
266 269 sync: &sync,
267 270 files,
@@ -372,6 +375,7 @@
372 375 let sync = Offline;
373 376 let files = FakeFiles::default();
374 377 let state = Panels {
378 + detail: &Unfocused,
375 379 config: &store,
376 380 sync: &sync,
377 381 files: &files,
@@ -417,6 +421,7 @@
417 421 let sync = Offline;
418 422 let files = FakeFiles::default();
419 423 let state = Panels {
424 + detail: &Unfocused,
420 425 config: &store,
421 426 sync: &sync,
422 427 files: &files,
@@ -456,6 +461,7 @@
456 461 let sync = Offline;
457 462 let files = FakeFiles::default();
458 463 let state = Panels {
464 + detail: &Unfocused,
459 465 config: &store,
460 466 sync: &sync,
461 467 files: &files,
@@ -481,6 +487,7 @@
481 487 let sync = Offline;
482 488 let files = FakeFiles::default();
483 489 let state = Panels {
490 + detail: &Unfocused,
484 491 config: &store,
485 492 sync: &sync,
486 493 files: &files,
@@ -529,6 +536,7 @@
529 536 let sync = Offline;
530 537 let files = FakeFiles::default();
531 538 let state = Panels {
539 + detail: &Unfocused,
532 540 config: &store,
533 541 sync: &sync,
534 542 files: &files,
@@ -573,6 +581,7 @@
573 581 let sync = Offline;
574 582 let files = FakeFiles::default();
575 583 let state = Panels {
584 + detail: &Unfocused,
576 585 config: &store,
577 586 sync: &sync,
578 587 files: &files,
@@ -723,6 +732,7 @@
723 732 let themes = themes();
724 733 let files = FakeFiles::default();
725 734 let state = Panels {
735 + detail: &Unfocused,
726 736 config: &store,
727 737 sync,
728 738 files: &files,
@@ -1629,3 +1639,514 @@
1629 1639 assert_eq!(export.asked.borrow().as_slice(), ["dismiss"]);
1630 1640 }
1631 1641 }
1642 +
1643 + // The detail panel.
1644 +
1645 + /// A detail panel with nothing chosen.
1646 + ///
1647 + /// [`Offline`]'s peer, and here for the same reason: `Panels` is one state for
1648 + /// every screen, so a settings test still has to name a detail panel.
1649 + struct Unfocused;
1650 +
1651 + impl Detail for Unfocused {
1652 + fn focus(&self) -> Focus {
1653 + Focus::Nothing
1654 + }
1655 +
1656 + fn add_tag(&self, _tag: &str) {}
1657 + fn remove_tag(&self, _tag: &str) {}
1658 + fn suggest(&self) {}
1659 + fn accept(&self, _tag: &str) {}
1660 + fn copy_path(&self) {}
1661 + fn edit(&self) {}
1662 + fn forge(&self) {}
1663 + fn find_similar(&self) {}
1664 + fn find_duplicates(&self) {}
1665 + fn spread_tag(&self, _tag: &str) {}
1666 + fn strip_tag(&self, _tag: &str) {}
1667 + fn edit_selection(&self) {}
1668 + }
1669 +
1670 + /// A detail panel in memory, recording what was asked of it.
1671 + struct FakeDetail {
1672 + focus: Focus,
1673 + asked: RefCell<Vec<String>>,
1674 + }
1675 +
1676 + impl FakeDetail {
1677 + fn at(focus: Focus) -> Self {
1678 + Self {
1679 + focus,
1680 + asked: RefCell::new(Vec::new()),
1681 + }
1682 + }
1683 +
1684 + fn note(&self, what: impl Into<String>) {
1685 + self.asked.borrow_mut().push(what.into());
1686 + }
1687 +
1688 + fn asked(&self) -> Vec<String> {
1689 + self.asked.borrow().clone()
1690 + }
1691 + }
1692 +
1693 + impl Detail for FakeDetail {
1694 + fn focus(&self) -> Focus {
1695 + self.focus.clone()
1696 + }
1697 +
1698 + fn add_tag(&self, tag: &str) {
1699 + self.note(format!("add {tag}"));
1700 + }
1701 +
1702 + fn remove_tag(&self, tag: &str) {
1703 + self.note(format!("remove {tag}"));
1704 + }
1705 +
1706 + fn suggest(&self) {
1707 + self.note("suggest");
1708 + }
1709 +
1710 + fn accept(&self, tag: &str) {
1711 + self.note(format!("accept {tag}"));
1712 + }
1713 +
1714 + fn copy_path(&self) {
1715 + self.note("copy");
1716 + }
1717 +
1718 + fn edit(&self) {
1719 + self.note("edit");
1720 + }
1721 +
1722 + fn forge(&self) {
1723 + self.note("forge");
1724 + }
1725 +
1726 + fn find_similar(&self) {
1727 + self.note("similar");
1728 + }
1729 +
1730 + fn find_duplicates(&self) {
1731 + self.note("duplicates");
1732 + }
1733 +
1734 + fn spread_tag(&self, tag: &str) {
1735 + self.note(format!("spread {tag}"));
1736 + }
1737 +
1738 + fn strip_tag(&self, tag: &str) {
1739 + self.note(format!("strip {tag}"));
1740 + }
1741 +
1742 + fn edit_selection(&self) {
1743 + self.note("bulk");
1744 + }
1745 + }
1746 +
1747 + /// A router call against this detail panel.
1748 + fn detailing(detail: &FakeDetail, request: Request) -> Result<Response, quasi_router::RouteError> {
1749 + let store = Store::default();
1750 + let sync = Offline;
1751 + let files = FakeFiles::default();
1752 + let themes = themes();
1753 + let state = Panels {
1754 + config: &store,
1755 + sync: &sync,
1756 + files: &files,
1757 + export: &Idle,
1758 + detail,
1759 + themes: &themes,
1760 + };
1761 + router().handle(&state, request)
1762 + }
1763 +
1764 + /// The screen the detail panel answers, at whatever it is focused on.
1765 + fn detailed(detail: &FakeDetail) -> Screen {
1766 + screen_of(&detailing(detail, Request::get("/detail")).unwrap()).clone()
1767 + }
1768 +
1769 + /// A sample with everything analysis can find.
1770 + fn analysed() -> Detailed {
1771 + Detailed {
1772 + id: 7,
1773 + name: "kick.wav".to_owned(),
1774 + path: Some("/vault/kick.wav".to_owned()),
1775 + analysis: Some(Analysis {
1776 + duration: 1.5,
1777 + sample_rate: 48_000,
1778 + channels: 2,
1779 + bpm: Some(120.0),
1780 + musical_key: Some("Am".to_owned()),
1781 + peak_db: Some(-3.2),
1782 + rms_db: Some(-14.0),
1783 + lufs: Some(-11.5),
1784 + is_loop: Some(false),
1785 + }),
1786 + tags: vec![
1787 + Tagged {
1788 + name: "drums".to_owned(),
1789 + source: Source::Manual,
1790 + },
1791 + Tagged {
1792 + name: "kick".to_owned(),
1793 + source: Source::Rule,
1794 + },
1795 + ],
1796 + suggestions: Vec::new(),
1797 + is_sample: true,
1798 + has_spectral: true,
1799 + has_fingerprint: true,
1800 + }
1801 + }
1802 +
1803 + /// One sample, focused.
1804 + fn one(sample: Detailed) -> Focus {
1805 + Focus::One(Box::new(sample))
1806 + }
1807 +
1808 + /// Several samples, focused.
1809 + fn several(spread: Spread) -> Focus {
1810 + Focus::Several(Box::new(spread))
1811 + }
1812 +
1813 + /// A selection of two that agrees about nothing.
1814 + fn mixed() -> Spread {
1815 + Spread {
1816 + samples: 2,
1817 + folders: 1,
1818 + bpm: Shared::Varies,
1819 + musical_key: Shared::Absent,
1820 + duration: Shared::Same("1.5s".to_owned()),
1821 + tags: vec![
1822 + Coverage {
1823 + name: "drums".to_owned(),
1824 + on: 2,
1825 + },
1826 + Coverage {
1827 + name: "loop".to_owned(),
1828 + on: 1,
1829 + },
1830 + ],
1831 + }
1832 + }
1833 +
1834 + /// Every act on a screen that is not answering, by label.
1835 + fn dead(screen: &Screen) -> Vec<String> {
1836 + nodes(screen)
1837 + .iter()
1838 + .filter_map(|node| match node {
1839 + Node::Act(act) if act.state == Some(quasi_router::layout::State::Disabled) => {
1840 + Some(act.label.clone())
1841 + }
1842 + _ => None,
1843 + })
1844 + .collect()
1845 + }
1846 +
1847 + #[test]
1848 + fn nothing_chosen_says_so_and_offers_nothing() {
1849 + let detail = FakeDetail::at(Focus::Nothing);
1850 + let screen = detailed(&detail);
1851 +
1852 + assert!(said(&screen).contains("Select a sample"));
1853 + assert!(acts(&screen).is_empty());
1854 + }
1855 +
1856 + #[test]
1857 + fn one_sample_reports_every_field_analysis_found() {
1858 + let detail = FakeDetail::at(one(analysed()));
1859 + let screen = detailed(&detail);
1860 + let (_, rows) = table_of(&screen);
1861 +
1862 + let facts: Vec<(String, String)> = rows
1863 + .iter()
1864 + .map(|row| (cell_text(row, 0), cell_text(row, 1)))
1865 + .collect();
1866 + let field = |name: &str| {
1867 + facts
1868 + .iter()
1869 + .find(|(field, _)| field == name)
1870 + .map(|(_, value)| value.clone())
1871 + };
1872 +
1873 + assert_eq!(field("Duration").as_deref(), Some("1.5s"));
1874 + assert_eq!(field("BPM").as_deref(), Some("120"));
1875 + assert_eq!(field("Key").as_deref(), Some("Am"));
1876 + assert_eq!(field("Sample rate").as_deref(), Some("48000 Hz"));
1877 + assert_eq!(field("Channels").as_deref(), Some("2"));
1878 + assert_eq!(field("Peak").as_deref(), Some("-3.2 dB"));
1879 + assert_eq!(field("RMS").as_deref(), Some("-14.0 dB"));
1880 + assert_eq!(field("LUFS").as_deref(), Some("-11.5"));
1881 + assert_eq!(field("Loop").as_deref(), Some("No"));
1882 + }
1883 +
1884 + #[test]
1885 + fn a_field_analysis_did_not_find_is_absent_rather_than_blank() {
1886 + let mut sample = analysed();
1887 + if let Some(analysis) = sample.analysis.as_mut() {
1888 + analysis.bpm = None;
1889 + analysis.musical_key = None;
1890 + analysis.lufs = None;
1891 + }
1892 + let detail = FakeDetail::at(one(sample));
1893 + let (_, rows) = table_of(&detailed(&detail));
1894 +
1895 + let fields: Vec<String> = rows.iter().map(|row| cell_text(row, 0)).collect();
1896 + assert!(!fields.iter().any(|field| field == "BPM"));
1897 + assert!(!fields.iter().any(|field| field == "Key"));
1898 + assert!(!fields.iter().any(|field| field == "LUFS"));
1899 + assert!(fields.iter().any(|field| field == "Duration"));
1900 + }
1901 +
1902 + #[test]
1903 + fn a_tag_carries_where_it_came_from_and_removes_itself() {
1904 + let detail = FakeDetail::at(one(analysed()));
1905 + let screen = detailed(&detail);
1906 +
1907 + let tokens: Vec<quasi_router::Tag> = nodes(&screen)
1908 + .iter()
1909 + .filter_map(|node| match node {
1910 + Node::Token(tag) => Some(tag.clone()),
1911 + _ => None,
1912 + })
1913 + .collect();
1914 + assert_eq!(tokens.len(), 2);
1915 + assert!(tokens[0].label.contains("drums"));
1916 + assert!(tokens[0].label.contains("manual"));
1917 + assert!(tokens[1].label.contains("rule"));
1918 +
1919 + // Every one of them is removable and says where the removal goes, which is
1920 + // what the shipped panel's `tag_chip_removable` does with a bool.
1921 + for tag in &tokens {
1922 + assert_eq!(
1923 + tag.kind,
1924 + quasi_router::layout::Token::Chip { removable: true }
1925 + );
1926 + assert!(tag.action.is_some());
1927 + }
1928 + }
1929 +
1930 + #[test]
1931 + fn removing_a_tag_asks_for_that_tag() {
1932 + let detail = FakeDetail::at(one(analysed()));
1933 + detailing(&detail, Request::post("/detail/tags/drums/remove")).unwrap();
1934 + assert_eq!(detail.asked(), ["remove drums"]);
1935 + }
1936 +
1937 + #[test]
1938 + fn adding_a_tag_carries_what_was_typed_and_refuses_an_empty_one() {
1939 + let detail = FakeDetail::at(one(analysed()));
1940 + detailing(
1941 + &detail,
1942 + Request::post("/detail/tags")
1943 + .sending(Params::new().with("tag".to_owned(), "genre.house".to_owned())),
1944 + )
1945 + .unwrap();
1946 + assert_eq!(detail.asked(), ["add genre.house"]);
1947 +
1948 + let empty = FakeDetail::at(one(analysed()));
1949 + let response = detailing(
1950 + &empty,
1951 + Request::post("/detail/tags").sending(Params::new().with("tag".to_owned(), String::new())),
1952 + )
1953 + .unwrap();
1954 + assert!(empty.asked().is_empty());
1955 + assert!(response.notice.is_some());
1956 + }
1957 +
1958 + #[test]
1959 + fn a_suggestion_says_its_score_and_how_many_carry_it() {
1960 + let mut sample = analysed();
1961 + sample.suggestions = vec![Suggested {
1962 + tag: "percussion".to_owned(),
1963 + score: 0.82,
1964 + neighbours: 4,
1965 + }];
1966 + let detail = FakeDetail::at(one(sample));
1967 + let labels = acts(&detailed(&detail));
1968 +
1969 + let offer = labels
1970 + .iter()
1971 + .find(|label| label.contains("percussion"))
1972 + .expect("the suggestion is offered");
1973 + assert!(offer.contains("82%"));
1974 + assert!(offer.contains('4'));
1975 + }
1976 +
1977 + #[test]
1978 + fn discovery_is_offered_dead_with_its_precondition_said_beside_it() {
1979 + let mut sample = analysed();
1980 + sample.has_spectral = false;
1981 + sample.has_fingerprint = false;
1982 + let detail = FakeDetail::at(one(sample));
1983 + let screen = detailed(&detail);
1984 +
1985 + // Offered rather than hidden, which is the shipped panel's choice: a
1986 + // control that vanishes teaches nothing.
1987 + assert!(acts(&screen).iter().any(|label| label == "Find similar"));
1988 + assert_eq!(dead(&screen), ["Find similar", "Find duplicates"]);
1989 +
1990 + // And the sentence that would revive each is said. THE FINDING is that it
1991 + // is said beside the control rather than on it -- see the module header.
1992 + let says = said(&screen);
1993 + assert!(says.contains("spectral features"));
1994 + assert!(says.contains("fingerprinting"));
1995 + }
1996 +
1997 + #[test]
1998 + fn discovery_answers_where_the_features_are_there() {
1999 + let detail = FakeDetail::at(one(analysed()));
2000 + let screen = detailed(&detail);
2001 + assert!(dead(&screen).is_empty());
2002 +
2003 + detailing(&detail, Request::post("/detail/similar")).unwrap();
2004 + detailing(&detail, Request::post("/detail/duplicates")).unwrap();
2005 + assert_eq!(detail.asked(), ["similar", "duplicates"]);
2006 + }
2007 +
2008 + #[test]
2009 + fn discovery_refuses_a_typed_request_the_control_would_have_refused() {
2010 + let mut sample = analysed();
2011 + sample.has_spectral = false;
2012 + sample.has_fingerprint = false;
2013 + let detail = FakeDetail::at(one(sample));
2014 +
2015 + assert!(detailing(&detail, Request::post("/detail/similar")).is_err());
2016 + assert!(detailing(&detail, Request::post("/detail/duplicates")).is_err());
2017 + assert!(detail.asked().is_empty());
2018 + }
2019 +
2020 + #[test]
2021 + fn a_folder_is_offered_neither_the_editors_nor_discovery() {
2022 + let mut sample = analysed();
2023 + sample.is_sample = false;
2024 + let detail = FakeDetail::at(one(sample));
2025 + let labels = acts(&detailed(&detail));
2026 +
2027 + assert!(!labels.iter().any(|label| label == "Edit"));
2028 + assert!(!labels.iter().any(|label| label == "Forge"));
2029 + assert!(!labels.iter().any(|label| label == "Find similar"));
2030 + // The path is still copyable: a folder has one.
2031 + assert!(labels.iter().any(|label| label == "Copy path"));
2032 + }
2033 +
2034 + #[test]
2035 + fn several_chosen_says_what_they_agree_on_three_ways() {
2036 + let detail = FakeDetail::at(several(mixed()));
2037 + let screen = detailed(&detail);
2038 + let (_, rows) = table_of(&screen);
2039 +
2040 + let facts: Vec<(String, String)> = rows
2041 + .iter()
2042 + .map(|row| (cell_text(row, 0), cell_text(row, 1)))
2043 + .collect();
2044 +
2045 + // Three answers rather than the shipped panel's two strings: disagreement
2046 + // and absence are different facts and each renderer can now tell them
2047 + // apart.
2048 + assert_eq!(facts[0], ("BPM".to_owned(), "varies".to_owned()));
2049 + assert_eq!(facts[1], ("Key".to_owned(), "\u{2014}".to_owned()));
2050 + assert_eq!(facts[2], ("Duration".to_owned(), "1.5s".to_owned()));
2051 +
2052 + assert!(said(&screen).contains("2 samples \u{b7} 1 folders selected"));
2053 + }
2054 +
2055 + #[test]
2056 + fn a_partly_covered_tag_says_how_far_it_reaches_and_offers_both_ways() {
2057 + let detail = FakeDetail::at(several(mixed()));
2058 + let screen = detailed(&detail);
2059 +
2060 + let rows = list_of(&screen);
2061 + let full = &rows[0];
2062 + let partial = &rows[1];
2063 +
2064 + // The count is in the row rather than in a hover, so a reader with no
Lines truncated
@@ -400,7 +400,7 @@
400 400 /// `None` when the selection is empty or the first item lacks the field (nothing
401 401 /// to show), `Some(Err(()))` when the values differ or any item lacks the field
402 402 /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`.
403 - fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
403 + pub(crate) fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
404 404 where
405 405 F: Fn(&T) -> Option<V>,
406 406 V: PartialEq,
@@ -1,0 +1,572 @@
1 + //! The detail panel, described rather than built.
2 + //!
3 + //! The fifth audiofiles screen, and the first whose subject is *the selection*
4 + //! rather than an address. `sync` established that a state machine is four
5 + //! screens at one route; `export` established the same for a flow. This is the
6 + //! third and the shape has stopped being a discovery: **one route answers
7 + //! however many screens the app's state has, because the state is something
8 + //! that happened and not somewhere you can go.** Nothing navigates to "three
9 + //! samples are chosen".
10 + //!
11 + //! # What the description deletes
12 + //!
13 + //! The multi-selection reduction stays in the app (`ui::detail::summarize`,
14 + //! made `pub(crate)` for this) and everything around it goes. The shipped panel
15 + //! writes "varies" in three places, each as its own `match` over
16 + //! `Option<Result<V, ()>>` with its own em-dash fallback; here that is
17 + //! [`Shared`] and one function. A renderer that wants to draw disagreement
18 + //! differently from absence now can, and until this port the two were the same
19 + //! string.
20 + //!
21 + //! # What is deliberately not described
22 + //!
23 + //! - **The waveform.** 100 lines of it, and every one is a host fact: a
24 + //! click maps a pixel to a frame, a hover paints a line at the pointer, and
25 + //! the playback cursor is read out of a mutex a worker is filling. None of
26 + //! that is a fact about a sample. `Node::Image` would be the nearest member
27 + //! and it is not near: an image is a picture at an address, and this is a
28 + //! canvas that answers a pointer.
29 + //! - **The Tab-from-table focus handoff.** `state.focus_tag_input` asks the tag
30 + //! field to take focus this frame, which is a fact about a keyboard and a
31 + //! window rather than about the screen. `Act::key` names the key that reaches
32 + //! a control, and there is no member that says "this field has the caret now"
33 + //! — correctly, because that is what a host's focus ring is for.
34 + //! - **The collapsing sections.** Whether Metadata is open is remembered per
35 + //! `id_salt` by egui. `Node::section` says a section starts; whether the host
36 + //! lets a reader fold it is renderer policy, and the settings port settled
37 + //! that already.
38 + //!
39 + //! # THE FINDINGS, and both are second consumers
40 + //!
41 + //! **1. A control that is offered but not available cannot say why.** The two
42 + //! Discovery buttons are drawn disabled with the sentence that would make them
43 + //! work: "Re-analyze this sample with spectral features enabled to find similar
44 + //! samples." [`Act`] has [`State::Disabled`](quasi_router::layout::State) and
45 + //! nothing else, so the description can say the button is dead and not what
46 + //! would revive it. Every renderer then either drops the sentence or invents
47 + //! somewhere to put it.
48 + //!
49 + //! This is makeover-layout `e761833e` — "an option that is offered but not
50 + //! currently available, and the precondition that would make it available, has
51 + //! no description" — arriving from the other side. That one is about a
52 + //! [`Choice`](quasi_router::Choice) inside a picker; this is an [`Act`]. Same
53 + //! missing fact, two members, which is what a second consumer looks like. Filed
54 + //! rather than invented here.
55 + //!
56 + //! Note what this port did *not* do: it did not drop the disabled controls, and
57 + //! it did not fold the precondition into the label. Both would have hidden the
58 + //! gap. The buttons are described as disabled and the sentence is said beside
59 + //! them as prose, which is honest and slightly wrong in exactly the way the
60 + //! finding predicts.
61 + //!
62 + //! **2. Handing text to the clipboard is a host act with no vocabulary.**
63 + //! `Copy Path` is `ui.ctx().copy_text(path)`. It is the same shape as opening an
64 + //! address outside the app, which quasi answers with
65 + //! [`Outcome::Goto`](quasi_router::Outcome) and every host performs its own way
66 + //! — and there is no clipboard equivalent, so this port routes it through an
67 + //! [`Intent`](super::Intent) and the host copies. That works and it is the
68 + //! wrong layer: an intent is for the app's own UI state, and a clipboard is the
69 + //! *system's*. Written down rather than worked around quietly.
70 +
71 + use quasi_router::layout::{FieldKind, Notice, Tone};
72 + use quasi_router::{
73 + Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag,
74 + };
75 +
76 + use super::{Analysis, Coverage, Detailed, Focus, Panels, Shared, Source, Spread, Suggested};
77 +
78 + /// The region the screen answers into.
79 + const BODY: &str = "detail-body";
80 +
81 + /// The field a tag is typed into.
82 + const TAG: &str = "tag";
83 +
84 + /// Register this screen's routes.
85 + ///
86 + /// Everything is a `POST` to `/detail/...` and the answer is always the same
87 + /// screen, because there is only one: what changes is the selection, and the
88 + /// selection is not addressable. See this module's header.
89 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
90 + router
91 + .get("/detail", index)
92 + .post("/detail/tags", add_tag)
93 + .post("/detail/tags/{tag}/remove", remove_tag)
94 + .post("/detail/tags/suggest", suggest)
95 + .post("/detail/tags/{tag}/accept", accept)
96 + .post("/detail/path/copy", copy_path)
97 + .post("/detail/edit", edit)
98 + .post("/detail/forge", forge)
99 + .post("/detail/similar", find_similar)
100 + .post("/detail/duplicates", find_duplicates)
101 + .post("/detail/selection/tags/{tag}/spread", spread_tag)
102 + .post("/detail/selection/tags/{tag}/strip", strip_tag)
103 + .post("/detail/selection/edit", edit_selection)
104 + }
105 +
106 + /// `GET /detail`
107 + fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
108 + Ok(screen(state).into())
109 + }
110 +
111 + /// `POST /detail/tags`
112 + ///
113 + /// The tag is validated by the app, which already refuses an invalid one with a
114 + /// status message. What this refuses is the empty submission, because a control
115 + /// that appears to do nothing is worse than one that says why.
116 + fn add_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
117 + let tag = request.payload.get(TAG).unwrap_or_default().trim();
118 + if tag.is_empty() {
119 + return Ok(Response::from(screen(state)).toast(Tone::Danger, "Type a tag first."));
120 + }
121 + state.detail.add_tag(tag);
122 + Ok(screen(state).into())
123 + }
124 +
125 + /// `POST /detail/tags/{tag}/remove`
126 + fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
127 + let tag = named(&request)?;
128 + state.detail.remove_tag(&tag);
129 + Ok(screen(state).into())
130 + }
131 +
132 + /// `POST /detail/tags/suggest`
133 + fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
134 + state.detail.suggest();
135 + Ok(screen(state).into())
136 + }
137 +
138 + /// `POST /detail/tags/{tag}/accept`
139 + fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
140 + let tag = named(&request)?;
141 + state.detail.accept(&tag);
142 + Ok(screen(state).into())
143 + }
144 +
145 + /// `POST /detail/path/copy`
146 + fn copy_path(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
147 + state.detail.copy_path();
148 + Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
149 + }
150 +
151 + /// `POST /detail/edit`
152 + fn edit(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
153 + state.detail.edit();
154 + Ok(screen(state).into())
155 + }
156 +
157 + /// `POST /detail/forge`
158 + fn forge(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
159 + state.detail.forge();
160 + Ok(screen(state).into())
161 + }
162 +
163 + /// `POST /detail/similar`
164 + ///
165 + /// Refused where the features it reads were never computed, and that refusal is
166 + /// the route's rather than only the button's: an address is reachable by typing,
167 + /// so a disabled control is an affordance and not a guarantee. The shipped panel
168 + /// has only the button, which is why this is the one place the described version
169 + /// is stricter than what it ports.
170 + fn find_similar(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
171 + if !one(state).is_some_and(|sample| sample.has_spectral) {
172 + return Err(RouteError::not_found(SPECTRAL));
173 + }
174 + state.detail.find_similar();
175 + Ok(screen(state).into())
176 + }
177 +
178 + /// `POST /detail/duplicates`
179 + fn find_duplicates(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
180 + if !one(state).is_some_and(|sample| sample.has_fingerprint) {
181 + return Err(RouteError::not_found(FINGERPRINT));
182 + }
183 + state.detail.find_duplicates();
184 + Ok(screen(state).into())
185 + }
186 +
187 + /// `POST /detail/selection/tags/{tag}/spread`
188 + fn spread_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
189 + let tag = named(&request)?;
190 + state.detail.spread_tag(&tag);
191 + Ok(screen(state).into())
192 + }
193 +
194 + /// `POST /detail/selection/tags/{tag}/strip`
195 + fn strip_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
196 + let tag = named(&request)?;
197 + state.detail.strip_tag(&tag);
198 + Ok(screen(state).into())
199 + }
200 +
201 + /// `POST /detail/selection/edit`
202 + fn edit_selection(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
203 + state.detail.edit_selection();
204 + Ok(screen(state).into())
205 + }
206 +
207 + /// The tag a request names.
208 + fn named(request: &Request) -> Result<String, RouteError> {
209 + Ok(request.captures.require("tag")?.to_owned())
210 + }
211 +
212 + /// The sample in focus, if one is.
213 + fn one(state: &Panels<'_>) -> Option<Detailed> {
214 + match state.detail.focus() {
215 + Focus::One(sample) => Some(*sample),
216 + Focus::Nothing | Focus::Several(_) => None,
217 + }
218 + }
219 +
220 + /// What the two discovery paths need, said the way the shipped panel says it.
221 + const SPECTRAL: &str =
222 + "Re-analyze this sample with spectral features enabled to find similar samples.";
223 + const FINGERPRINT: &str = "Re-analyze this sample with fingerprinting enabled to find duplicates.";
224 +
225 + /// The screen, which is a different screen per selection.
226 + fn screen(state: &Panels<'_>) -> Screen {
227 + let body = Slot::new(BODY, RegionKind::Pane);
228 + let body = match state.detail.focus() {
229 + Focus::Nothing => body.with(Node::empty("Select a sample")),
230 + Focus::One(sample) => one_sample(body, &sample),
231 + Focus::Several(spread) => several(body, &spread),
232 + };
233 + Screen::sidebar_content("Detail").with(body)
234 + }
235 +
236 + /// One sample: what it is, what it is tagged with, and what can be done to it.
237 + fn one_sample(body: Slot, sample: &Detailed) -> Slot {
238 + let mut body = body.with(Node::page(&sample.name));
239 +
240 + if let Some(analysis) = &sample.analysis {
241 + body = metadata(body, analysis);
242 + }
243 + body = tags(body, sample);
244 + body = actions(body, sample);
245 + discovery(body, sample)
246 + }
247 +
248 + /// What analysis found, as a table of facts.
249 + ///
250 + /// A two-column table rather than a strip of [`Node::Stats`], and the difference
251 + /// is the claim: a figure strip says "these are the numbers this screen is
252 + /// about", which is right for a dashboard and wrong here — sample rate and
253 + /// channel count are properties of a file, not headline figures. The shipped
254 + /// panel draws an `egui::Grid` of label/value pairs and that is what this is.
255 + fn metadata(body: Slot, analysis: &Analysis) -> Slot {
256 + use quasi_router::{Cell, Cells, Column};
257 +
258 + let mut rows = vec![
259 + fact("Duration", seconds(analysis.duration)),
260 + fact("Sample rate", format!("{} Hz", analysis.sample_rate)),
261 + fact("Channels", analysis.channels.to_string()),
262 + ];
263 + if let Some(bpm) = analysis.bpm {
264 + rows.insert(1, fact("BPM", format!("{bpm:.0}")));
265 + }
266 + if let Some(key) = &analysis.musical_key {
267 + rows.insert(if analysis.bpm.is_some() { 2 } else { 1 }, fact("Key", key));
268 + }
269 + if let Some(peak) = analysis.peak_db {
270 + rows.push(fact("Peak", format!("{peak:.1} dB")));
271 + }
272 + if let Some(rms) = analysis.rms_db {
273 + rows.push(fact("RMS", format!("{rms:.1} dB")));
274 + }
275 + if let Some(lufs) = analysis.lufs {
276 + rows.push(fact("LUFS", format!("{lufs:.1}")));
277 + }
278 + if let Some(is_loop) = analysis.is_loop {
279 + rows.push(fact("Loop", if is_loop { "Yes" } else { "No" }));
280 + }
281 +
282 + body.with(Node::section("Metadata")).with(Node::Table {
283 + columns: vec![Column::new("Field"), Column::new("Value")],
284 + rows: rows
285 + .into_iter()
286 + .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)]))
287 + .collect(),
288 + })
289 + }
290 +
291 + /// One label and one value.
292 + fn fact(field: &str, value: impl Into<String>) -> (String, String) {
293 + (field.to_owned(), value.into())
294 + }
295 +
296 + /// What it is tagged with, where each tag came from, and what may be added.
297 + ///
298 + /// The provenance is a [`Tone`] on the token rather than a second collapsed
299 + /// section listing the same tags again. The shipped panel has both — chips at
300 + /// the top, a "Tag sources" fold underneath repeating every tag with a coloured
301 + /// word beside it — and the fold exists because a chip had nowhere to carry the
302 + /// fact. A token does: it has a tone, and the tone is what the fold was
303 + /// colouring anyway.
304 + fn tags(body: Slot, sample: &Detailed) -> Slot {
305 + let mut body = body.with(Node::section("Tags"));
306 +
307 + if sample.tags.is_empty() {
308 + body = body.with(Node::text("No tags"));
309 + } else {
310 + for tagged in &sample.tags {
311 + body = body.with(Node::Token(Tag {
312 + kind: quasi_router::layout::Token::Chip { removable: true },
313 + label: format!("{} ({})", tagged.name, tagged.source.as_str()),
314 + tone: tone_of(&tagged.source),
315 + latched: false,
316 + action: Some(Action::post(format!("/detail/tags/{}/remove", tagged.name))),
317 + }));
318 + }
319 + }
320 +
321 + body = body.with(Node::Form {
322 + fields: vec![Field::new(FieldKind::Text, TAG, "Add tag").hint("Use dots: genre.house")],
323 + submit: "Add".to_owned(),
324 + action: Action::post("/detail/tags"),
325 + });
326 +
327 + body = body.with(Node::Act(Act::new(
328 + "Suggest similar tags",
329 + Action::post("/detail/tags/suggest"),
330 + )));
331 +
332 + for suggestion in &sample.suggestions {
333 + body = body.with(Node::Act(Act::new(
334 + offer(suggestion),
335 + Action::post(format!("/detail/tags/{}/accept", suggestion.tag)),
336 + )));
337 + }
338 + body
339 + }
340 +
341 + /// A suggestion, as the control that takes it reads.
342 + ///
343 + /// The score and the neighbour count are in the label rather than in a hover,
344 + /// because a hover is a pointer affordance and the description has readers with
345 + /// no pointer. The shipped panel puts the count in `on_hover_text`, which a
346 + /// terminal renderer would have lost.
347 + fn offer(suggestion: &Suggested) -> String {
348 + format!(
349 + "Add {} ({:.0}%, on {} similar)",
350 + suggestion.tag,
351 + suggestion.score * 100.0,
352 + suggestion.neighbours,
353 + )
354 + }
355 +
356 + /// What provenance reads as.
357 + ///
358 + /// Four sources onto three tones, which is a narrowing the shipped panel does
359 + /// not do: it gives each source its own palette entry, including two of the
360 + /// categorical colours, which are for telling series apart rather than for
361 + /// meaning anything. A tone says what a thing *is*, so a tag the app derived and
362 + /// a tag a rule matched are both "the app did this" and a hand-typed one is an
363 + /// ordinary fact.
364 + fn tone_of(source: &Source) -> Tone {
365 + match source {
366 + Source::Manual => Tone::Neutral,
367 + Source::Rule | Source::Folder => Tone::Info,
368 + Source::Suggested | Source::Cluster | Source::Other(_) => Tone::Warning,
369 + }
370 + }
371 +
372 + /// What can be done to the sample.
373 + fn actions(body: Slot, sample: &Detailed) -> Slot {
374 + let mut body = body.with(Node::section("Actions"));
375 + if sample.path.is_some() {
376 + body = body.with(Node::Act(Act::new(
377 + "Copy path",
378 + Action::post("/detail/path/copy"),
379 + )));
380 + }
381 + if sample.is_sample {
382 + body = body
383 + .with(Node::Act(
384 + Act::new("Edit", Action::post("/detail/edit")).key("e"),
385 + ))
386 + .with(Node::Act(
387 + Act::new("Forge", Action::post("/detail/forge")).key("f"),
388 + ));
389 + }
390 + body
391 + }
392 +
393 + /// Finding related samples, and saying so when it cannot be done.
394 + ///
395 + /// Both controls are described whether or not they can run, which is the
396 + /// shipped panel's choice and the right one: a control that vanishes when its
397 + /// prerequisite is missing teaches nothing, and `add_enabled(false, ..)` with a
398 + /// disabled hover is what the panel does. See this module's header for what the
399 + /// description cannot yet carry across — the hover sentence, which is said as
400 + /// prose here because there is nowhere on the [`Act`] to put it.
401 + fn discovery(body: Slot, sample: &Detailed) -> Slot {
402 + if !sample.is_sample {
403 + return body;
404 + }
405 + let mut body = body.with(Node::section("Discovery"));
406 +
407 + let mut similar = Act::new("Find similar", Action::post("/detail/similar")).key("shift+f");
408 + if !sample.has_spectral {
409 + similar = similar.disabled();
410 + }
411 + body = body.with(Node::Act(similar));
412 +
413 + let mut duplicates =
414 + Act::new("Find duplicates", Action::post("/detail/duplicates")).key("shift+d");
415 + if !sample.has_fingerprint {
416 + duplicates = duplicates.disabled();
417 + }
418 + body = body.with(Node::Act(duplicates));
419 +
420 + // The preconditions, as prose beside the controls they are about. The
421 + // finding is that this belongs on the control.
422 + if !sample.has_spectral {
423 + body = body.with(Node::Notice {
424 + kind: Notice::Banner,
425 + tone: Tone::Info,
426 + text: SPECTRAL.to_owned(),
427 + });
428 + }
429 + if !sample.has_fingerprint {
430 + body = body.with(Node::Notice {
431 + kind: Notice::Banner,
432 + tone: Tone::Info,
433 + text: FINGERPRINT.to_owned(),
434 + });
435 + }
436 + body
437 + }
438 +
439 + /// Several samples: what they agree on, and what can be done to all of them.
440 + fn several(body: Slot, spread: &Spread) -> Slot {
441 + let heading = if spread.folders == 0 {
442 + format!("{} samples selected", spread.samples)
443 + } else {
444 + format!(
445 + "{} samples \u{b7} {} folders selected",
446 + spread.samples, spread.folders
447 + )
448 + };
449 + let mut body = body.with(Node::page(heading));
450 +
451 + if spread.samples == 0 {
452 + return body.with(Node::empty("No sample metadata to summarize"));
453 + }
454 +
455 + body = agreed(body, spread);
456 + body = coverage(body, spread);
457 + body.with(Node::Act(Act::new(
458 + "Edit as bulk",
459 + Action::post("/detail/selection/edit"),
460 + )))
461 + }
462 +
463 + /// What every chosen sample says, where they say the same thing.
464 + fn agreed(body: Slot, spread: &Spread) -> Slot {
465 + use quasi_router::{Cell, Cells, Column};
466 +
467 + body.with(Node::section("In common")).with(Node::Table {
468 + columns: vec![Column::new("Field"), Column::new("Value")],
469 + rows: [
470 + ("BPM", &spread.bpm),
471 + ("Key", &spread.musical_key),
472 + ("Duration", &spread.duration),
473 + ]
474 + .into_iter()
475 + .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(reads(value))]))
476 + .collect(),
477 + })
478 + }
479 +
480 + /// What a shared field reads as.
481 + ///
482 + /// Three answers where the shipped panel has two strings, because it collapsed
483 + /// [`Shared::Varies`] and [`Shared::Absent`] onto "varies" and an em dash
484 + /// without either being a described fact. Saying which it is here is what lets a
485 + /// renderer draw them differently.
486 + fn reads(shared: &Shared) -> String {
487 + match shared {
488 + Shared::Same(value) => value.clone(),
489 + Shared::Varies => "varies".to_owned(),
490 + Shared::Absent => "\u{2014}".to_owned(),
491 + }
492 + }
493 +
494 + /// Every tag any of them carries, with what it would take to make it unanimous.
495 + ///
496 + /// A [`Node::List`] rather than a wrap of tokens with a context menu on each,
497 + /// which is what the shipped panel has. A right-click menu is a pointer
498 + /// affordance; [`Row::menu`](quasi_router::Row) is the described form of the same
499 + /// thing and every host answers it its own way. The partial-coverage count is in
500 + /// the row's own text rather than in a hover, for the reason a suggestion's
Lines truncated