Skip to main content

max / audiofiles

Flip the detail panel to the described screen The panel serves from `quasi::detail` into the app's own right pane, and `ui/detail.rs` is gone. `summarize` came out of it rather than going with it, the same way `RENDER_ROWS` and the filter axes did: "they all say 90, or they vary" is a fact about a selection, and the described multi-selection body is what reads it now. `Described::show_detail` goes too. It existed so Settings could open the described panel beside the shipped one, and there is nothing beside anything here any more. The first flip to match with nothing granted at all -- no window frame, no allowances. The casing and the dead Copy Path that the parity harness found in this screen were fixed when the harness was built, and the field labels that would have been the rest of it are fixed and published.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 19:43 UTC
Signed with PGP, not checked
Commit: 4953cfdbe46d1d78f350284f029a65fff4a44107
Parent: ec2d6af
7 files changed, +50 insertions, -572 deletions
@@ -4,8 +4,8 @@
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 - detail, export_screens, file_list, footer, import_screens, instrument_panel, layout_strip,
8 - overlays, sidebar, theme, toolbar,
7 + export_screens, file_list, footer, import_screens, instrument_panel, layout_strip, overlays,
8 + sidebar, theme, toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -179,10 +179,6 @@
179 179 #[cfg(feature = "quasi")]
180 180 {
181 181 state.described.show_files = true;
182 - // The detail panel is the same case for the same reason: the
183 - // shipped one is a pane in the main window, so there is no toggle
184 - // to share and Settings opens it too.
185 - state.described.show_detail = true;
186 182 // And the main window, which is the one described screen that is
187 183 // more than a single region: the file list with the status band
188 184 // under it.
@@ -195,11 +191,6 @@
195 191 crate::quasi::panel::draw_files(ctx, state, sync_manager);
196 192 }
197 193
198 - #[cfg(feature = "quasi")]
199 - if state.described.show_detail {
200 - crate::quasi::panel::draw_detail(ctx, state);
201 - }
202 -
203 194 #[cfg(feature = "quasi")]
204 195 if state.described.show_shell {
205 196 crate::quasi::panel::draw_shell(ctx, state);
@@ -341,7 +332,7 @@
341 332 .size_range(200.0..=400.0)
342 333 .show(ui, |ui| {
343 334 egui::ScrollArea::vertical().show(ui, |ui| {
344 - detail::draw_detail(ui, state);
335 + crate::quasi::panel::draw_detail(ui, state);
345 336 });
346 337 });
347 338 }
@@ -81,6 +81,34 @@
81 81 /// The field a tag is typed into.
82 82 const TAG: &str = "tag";
83 83
84 + /// What a set of samples agree on, if they agree on anything.
85 + ///
86 + /// Lived in `ui::detail` until that module was deleted (2026-08-22) and came
87 + /// here rather than going with it: the described screen's multi-selection body
88 + /// is what reads it now, and "they all say 90, or they vary" is a fact about a
89 + /// selection rather than about a renderer.
90 + ///
91 + /// Reduce a field across a multi-selection to one displayable value. Returns
92 + /// `None` when the selection is empty or the first item lacks the field (nothing
93 + /// to show), `Some(Err(()))` when the values differ or any item lacks the field
94 + /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`.
95 + pub(crate) fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
96 + where
97 + F: Fn(&T) -> Option<V>,
98 + V: PartialEq,
99 + {
100 + let mut iter = items.iter().map(&extract);
101 + let first = iter.next()??;
102 + for v in iter {
103 + match v {
104 + Some(v) if v == first => {}
105 + Some(_) => return Some(Err(())),
106 + None => return Some(Err(())),
107 + }
108 + }
109 + Some(Ok(first))
110 + }
111 +
84 112 /// Register this screen's routes.
85 113 ///
86 114 /// Everything is a `POST` to `/detail/...` and the answer is always the same
@@ -1825,15 +1825,15 @@
1825 1825 samples: count,
1826 1826 folders: nodes.len().saturating_sub(count),
1827 1827 bpm: shared(
1828 - crate::ui::detail::summarize(&samples, |node| node.bpm),
1828 + crate::quasi::detail::summarize(&samples, |node| node.bpm),
1829 1829 |bpm| format!("{bpm:.0}"),
1830 1830 ),
1831 1831 musical_key: shared(
1832 - crate::ui::detail::summarize(&samples, |node| node.musical_key.clone()),
1832 + crate::quasi::detail::summarize(&samples, |node| node.musical_key.clone()),
1833 1833 |key| key,
1834 1834 ),
1835 1835 duration: shared(
1836 - crate::ui::detail::summarize(&samples, |node| node.duration),
1836 + crate::quasi::detail::summarize(&samples, |node| node.duration),
1837 1837 |seconds| format!("{seconds:.1}s"),
1838 1838 ),
1839 1839 tags,
@@ -63,12 +63,6 @@
63 63 naming: Option<Runtime>,
64 64 /// Whether the described main window is open.
65 65 pub show_shell: bool,
66 - /// Whether the described detail panel is open.
67 - ///
68 - /// [`show_files`](Self::show_files)'s twin and for the same reason: the
69 - /// shipped detail panel is a pane inside the main window rather than a
70 - /// window with a toggle of its own, so there is nothing to share.
71 - pub show_detail: bool,
72 66 /// Whether the described file list is open.
73 67 ///
74 68 /// Its own flag rather than the shipped list's, because the shipped list is
@@ -208,15 +202,15 @@
208 202 }
209 203 }
210 204
211 - /// Draw the described detail panel, and act on whatever was pressed.
205 + /// Draw the detail panel, and act on whatever was pressed.
212 206 ///
213 - /// **Refreshed unconditionally**, like the export flow and for a related reason:
214 - /// its subject is the selection, which the *shipped* file list changes. Every
215 - /// other described window moves only when something inside it was pressed; this
216 - /// one moves when the user clicks a row in a pane it does not know about, and
217 - /// there is no intent to hang a refresh on because nothing described was
218 - /// touched.
219 - pub fn draw_detail(ctx: &egui::Context, state: &mut BrowserState) {
207 + /// Into the app's own right pane rather than a window, because that is what the
208 + /// shipped panel was.
209 + ///
210 + /// Refreshed unconditionally: adding a tag and accepting a suggestion both write
211 + /// through an intent, so a screen that is not re-asked shows the tags from
212 + /// before the last press.
213 + pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
220 214 let intents = RefCell::new(Vec::new());
221 215 let mut runtime = state.described.detail.take();
222 216 let host = Host {
@@ -225,20 +219,9 @@
225 219 themes: themes(),
226 220 intents: &intents,
227 221 };
228 - let closed = window(
229 - ctx,
230 - "Detail (described)",
231 - &mut runtime,
232 - &host,
233 - "/detail",
234 - true,
235 - );
222 + inline(ui, &mut runtime, &host, "/detail", true);
236 223 state.described.detail = runtime;
237 - apply(ctx, state, None, intents.into_inner());
238 - if closed {
239 - state.described.show_detail = false;
240 - state.described.detail = None;
241 - }
224 + apply(ui.ctx(), state, None, intents.into_inner());
242 225 }
243 226
244 227 /// Draw the described main window, and act on whatever was pressed.
@@ -758,43 +758,20 @@
758 758 }
759 759
760 760 #[test]
761 - fn the_detail_panel_offers_what_the_shipped_one_offers() {
761 + fn the_detail_panel_serves_what_it_describes() {
762 762 let (mut state, _dir) = fixture();
763 763 // A selection, because the detail panel's subject is what is chosen and an
764 - // empty one is a different screen on both sides.
764 + // empty one is a different screen.
765 765 state.nav.selection.set_single(0);
766 766
767 767 let described = described(&super::panel::described_screen(&state, "/detail"));
768 - let shipped = shipped(|ui| {
769 - crate::ui::detail::draw_detail(ui, &mut state);
768 + let drawn = shipped(|ui| {
769 + super::panel::draw_detail(ui, &mut state);
770 770 });
771 771
772 772 described.addresses_resolve();
773 - Parity::strict()
774 - // The shipped panel's three sections are `CollapsingHeader`s, so each
775 - // heading is a control: what it does is fold. The description says a
776 - // heading, and folding is the renderer's -- a terminal or a narrow
777 - // webview may not fold at all. The press exists on one side because the
778 - // two disagree about whose business folding is, not about what the
779 - // screen offers.
780 - .dropping("Tags")
781 - .dropping("Actions")
782 - .dropping("Discovery")
783 - // The tag box: the shipped one is an unlabelled `TextEdit` beside a
784 - // button captioned "+", so a screen reader announces a text field with
785 - // no name and a button called plus. The description labels the field
786 - // and spells the submit, which is the port fixing something rather than
787 - // drifting from it.
788 - .dropping("+")
789 - .gaining("Add")
790 - .gaining("Add tag")
791 - // Copy Path, present on both sides and dead on one. The shipped button
792 - // is always alive and does nothing when there is no path to copy; the
793 - // described one says so. Changed here on 2026-08-22, following the
794 - // rule `discovery` states two functions below in the same file.
795 - .dropping("Copy Path")
796 - .gaining("Copy Path")
797 - .assert(&described, &shipped);
773 + // A right pane rather than a window, which is what the shipped panel was.
774 + Parity::strict().assert(&described, &drawn);
798 775 }
799 776
800 777 #[test]
@@ -2,7 +2,6 @@
2 2
3 3 pub mod classifier;
4 4 pub mod color;
5 - pub mod detail;
6 5 pub mod dialog;
7 6 pub mod export_screens;
8 7 pub mod file_list;
@@ -1,628 +1,0 @@
1 - //! Right detail panel: waveform display, metadata grid, tags, and copy-path button.
2 -
3 - use egui;
4 -
5 - use super::theme;
6 - use super::widgets;
7 - use crate::state::BrowserState;
8 - use crate::waveform;
9 -
10 - /// Draw the detail panel content for the currently selected sample.
11 - pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
12 - if state.nav.selection.count() > 1 {
13 - draw_multi_summary(ui, state);
14 - return;
15 - }
16 -
17 - let Some(node) = state.selected_node() else {
18 - widgets::empty_state(ui, "Select a sample", None, None);
19 - return;
20 - };
21 -
22 - // Waveform
23 - if let Some(ref waveform_data) = state.detail.selected_waveform {
24 - // Compute playback position as a 0.0–1.0 fraction for the waveform cursor.
25 - // Only valid when the currently-playing hash matches this node's hash.
26 - let playback_pos =
27 - if state.preview.previewing_hash.as_deref() == node.node.sample_hash.as_deref() {
28 - let playback = state.shared.preview.lock();
29 - if playback.playing {
30 - if let Some(ref buf) = playback.buffer {
31 - // During streaming, the buffer grows so use the metadata estimate
32 - // for a stable cursor. Fall back to current buffer size otherwise.
33 - let total_frames = if playback.streaming {
34 - playback
35 - .total_frames_estimate
36 - .unwrap_or(playback.decoded_frames)
37 - } else {
38 - buf.data.len() / 2
39 - };
40 - if total_frames > 0 {
41 - Some((playback.position_frac / total_frames as f64) as f32)
42 - } else {
43 - None
44 - }
45 - } else {
46 - None
47 - }
48 - } else {
49 - None
50 - }
51 - } else {
52 - None
53 - };
54 -
55 - let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0);
56 - // Hover indicator: paint a vertical action line at the cursor X
57 - // and a time label above it so the user can see where a click-to-seek
58 - // would land before committing.
59 - if resp.hovered()
60 - && let Some(pos) = resp.hover_pos()
61 - {
62 - let rect = resp.rect;
63 - let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
64 - let total_secs = waveform_data.duration as f32;
65 - let cursor_secs = normalized * total_secs;
66 - ui.painter().line_segment(
67 - [
68 - egui::pos2(pos.x, rect.top()),
69 - egui::pos2(pos.x, rect.bottom()),
70 - ],
71 - egui::Stroke::new(1.0, theme::action()),
72 - );
73 - let label = format!(
74 - "{:.0}:{:02.0}",
75 - (cursor_secs / 60.0).floor(),
76 - cursor_secs % 60.0,
77 - );
78 - ui.painter().text(
79 - egui::pos2(pos.x, rect.top() - 2.0),
80 - egui::Align2::CENTER_BOTTOM,
81 - label,
82 - egui::FontId::proportional(10.0),
83 - theme::content_secondary(),
84 - );
85 - }
86 - // Click-to-seek: map the click's X position to a 0.0–1.0 fraction
87 - // within the waveform rect, then set the playback cursor to that frame.
88 - if resp.clicked()
89 - && let Some(pos) = resp.interact_pointer_pos()
90 - {
91 - let rect = resp.rect;
92 - let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
93 - if let Some(hash) = &node.node.sample_hash
94 - && state.preview.previewing_hash.as_deref() == Some(hash)
95 - {
96 - let mut playback = state.shared.preview.lock();
97 - if let Some(ref buf) = playback.buffer {
98 - let total_frames = if playback.streaming {
99 - playback
100 - .total_frames_estimate
101 - .unwrap_or(playback.decoded_frames)
102 - } else {
103 - buf.data.len() / 2
104 - };
105 - playback.position_frac = (normalized as f64 * total_frames as f64)
106 - .min((playback.decoded_frames.max(1) - 1) as f64);
107 - }
108 - }
109 - }
110 -
111 - ui.add_space(theme::section_spacing());
112 - }
113 -
114 - // Sample name
115 - ui.label(egui::RichText::new(&node.node.name).strong().size(14.0));
116 - ui.add_space(theme::space::peer());
117 -
118 - // Analysis metadata grid
119 - if let Some(ref analysis) = state.detail.selected_analysis {
120 - egui::CollapsingHeader::new("Metadata")
121 - .id_salt("detail_metadata_section")
122 - .default_open(true)
123 - .show(ui, |ui| {
124 - egui::Grid::new("detail_metadata")
125 - .num_columns(2)
126 - .spacing([8.0, theme::grid_row_spacing()])
127 - .show(ui, |ui| {
128 - ui.label(egui::RichText::new("Duration").color(theme::content_secondary()));
129 - ui.label(widgets::format_duration(analysis.duration));
130 - ui.end_row();
131 -
132 - if let Some(bpm) = analysis.bpm {
133 - ui.label(egui::RichText::new("BPM").color(theme::content_secondary()));
134 - ui.label(widgets::format_bpm(bpm));
135 - ui.end_row();
136 - }
137 -
138 - if let Some(ref key) = analysis.musical_key {
139 - ui.label(egui::RichText::new("Key").color(theme::content_secondary()));
140 - ui.label(key);
141 - ui.end_row();
142 - }
143 -
144 - ui.label(
145 - egui::RichText::new("Sample Rate").color(theme::content_secondary()),
146 - );
147 - ui.label(format!("{} Hz", analysis.sample_rate));
148 - ui.end_row();
149 -
150 - ui.label(egui::RichText::new("Channels").color(theme::content_secondary()));
151 - ui.label(format!("{}", analysis.channels));
152 - ui.end_row();
153 -
154 - if let Some(peak) = analysis.peak_db {
155 - ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
156 - ui.label(format!("{peak:.1} dB"));
157 - ui.end_row();
158 - }
159 -
160 - if let Some(rms) = analysis.rms_db {
161 - ui.label(egui::RichText::new("RMS").color(theme::content_secondary()));
162 - ui.label(format!("{rms:.1} dB"));
163 - ui.end_row();
164 - }
165 -
166 - if let Some(lufs) = analysis.lufs {
167 - ui.label(egui::RichText::new("LUFS").color(theme::content_secondary()));
168 - ui.label(format!("{lufs:.1}"));
169 - ui.end_row();
170 - }
171 -
172 - if let Some(is_loop) = analysis.is_loop {
173 - ui.label(egui::RichText::new("Loop").color(theme::content_secondary()));
174 - ui.label(if is_loop { "Yes" } else { "No" });
175 - ui.end_row();
176 - }
177 - });
178 - });
179 - }
180 -
181 - ui.add_space(theme::section_spacing());
182 -
183 - egui::CollapsingHeader::new("Tags")
184 - .id_salt("detail_tags_section")
185 - .default_open(true)
186 - .show(ui, |ui| {
187 - if state.detail.selected_tags.is_empty() {
188 - ui.label(egui::RichText::new("No tags").color(theme::content_muted()));
189 - } else {
190 - ui.horizontal_wrapped(|ui| {
191 - let tags = state.detail.selected_tags.clone();
192 - for tag in tags.iter() {
193 - if widgets::tag_chip_removable(ui, tag, true) {
194 - // Remove tag and push an undoable entry so Cmd+Z restores it.
195 - if let Some(ref hash) = node.node.sample_hash {
196 - let hash_str = hash.to_string();
197 - if state.backend.remove_tag(hash, tag).is_ok() {
198 - state.push_undo(crate::state::UndoOp::TagRemove {
199 - hash: hash_str,
200 - tag: tag.clone(),
201 - });
202 - state.status = format!("Removed tag \"{tag}\"");
203 - state.refresh_selected_tags();
204 - }
205 - }
206 - }
207 - }
208 - });
209 - }
210 -
211 - // Tag input
212 - ui.horizontal(|ui| {
213 - let resp = widgets::text_field(
214 - ui,
215 - egui::TextEdit::singleline(&mut state.detail.tag_input)
216 - .hint_text("Add tag (use dots: genre.house)")
217 - .desired_width(ui.available_width() - 40.0),
218 - );
219 - // Honor the Tab-from-table shortcut: focus the tag input on this frame.
220 - if state.focus_tag_input {
221 - resp.request_focus();
222 - state.focus_tag_input = false;
223 - }
224 - if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
225 - || ui.small_button("+").on_hover_text("Add tag").clicked()
226 - {
227 - let tag = state.detail.tag_input.trim().to_string();
228 - if !tag.is_empty()
229 - && let Some(ref hash) = node.node.sample_hash
230 - {
231 - if audiofiles_core::tags::validate_tag(&tag).is_ok() {
232 - let _ = state.backend.add_tag(hash, &tag);
233 - state.detail.tag_input.clear();
234 - state.refresh_selected_tags();
235 - } else {
236 - state.status = format!("Invalid tag: {tag}");
237 - }
238 - }
239 - }
240 - });
241 -
242 - // Tag provenance: where each tag came from (manual vs rule/ml/cluster/folder),
243 - // so "why does this sample have this tag?" is answerable at a glance.
244 - if !state.detail.selected_tags.is_empty() {
245 - egui::CollapsingHeader::new("Tag sources")
246 - .id_salt("detail_tag_sources")
247 - .default_open(false)
248 - .show(ui, |ui| {
249 - let tags = state.detail.selected_tags.clone();
250 - for tag in tags.iter() {
251 - let (label, color) = match state.detail.selected_tag_sources.get(tag) {
252 - None => ("manual", theme::content_muted()),
253 - Some((source, _)) => match source.as_str() {
254 - "rule" => ("rule", theme::action()),
255 - "ml" => ("suggested", theme::category_five()),
256 - "cluster" => ("cluster", theme::category_six()),
257 - "harvest" => ("folder", theme::success()),
258 - other => (other, theme::content_muted()),
259 - },
260 - };
261 - ui.horizontal(|ui| {
262 - ui.label(egui::RichText::new(tag).small());
263 - ui.with_layout(
264 - egui::Layout::right_to_left(egui::Align::Center),
265 - |ui| {
266 - ui.label(egui::RichText::new(label).small().color(color));
267 - },
268 - );
269 - });
270 - }
271 - });
272 - }
273 -
274 - // k-NN tag suggestions from acoustically similar samples (on demand, so the
275 - // exemplar index is only built when the user asks).
276 - ui.add_space(theme::space::bound());
277 - if ui
278 - .button("Suggest similar tags")
279 - .on_hover_text("Find tags from samples that sound similar to this one")
280 - .clicked()
281 - {
282 - state.suggest_ml_for_selected();
283 - }
284 - if !state.detail.selected_ml_suggestions.is_empty() {
285 - let suggestions = state.detail.selected_ml_suggestions.clone();
286 - let mut accept: Option<String> = None;
287 - for s in &suggestions {
288 - ui.horizontal(|ui| {
289 - if ui.small_button("Add").clicked() {
290 - accept = Some(s.tag.clone());
291 - }
292 - ui.label(egui::RichText::new(&s.tag).small())
293 - .on_hover_text(format!(
294 - "{} similar sample(s) carry this tag",
295 - s.neighbors.len()
296 - ));
297 - ui.label(
298 - egui::RichText::new(format!("{:.0}%", s.score * 100.0))
299 - .small()
300 - .color(theme::content_muted()),
301 - );
302 - });
303 - }
304 - if let Some(tag) = accept {
305 - state.accept_ml_suggestion(&tag);
306 - }
307 - }
308 - }); // end of Tags CollapsingHeader
309 -
310 - egui::CollapsingHeader::new("Actions")
311 - .id_salt("detail_actions_section")
312 - .default_open(true)
313 - .show(ui, |ui| {
314 - ui.horizontal(|ui| {
315 - if ui
316 - .button("Copy Path")
317 - .on_hover_text("Copy file path to clipboard")
318 - .clicked()
319 - && let Some(path) = state.selected_sample_path()
320 - {
321 - state.status = format!("Copied: {path}");
322 - ui.ctx().copy_text(path);
323 - }
324 - if let Some(hash) = &node.node.sample_hash {
325 - let hash = hash.clone();
326 - if ui
327 - .button("Edit")
328 - .on_hover_text("Open sample editor (E)")
329 - .clicked()
330 - {
331 - state.open_edit_window(&hash);
332 - }
333 - if ui
334 - .button("Forge")
335 - .on_hover_text("Chop / conform / batch (F)")
336 - .clicked()
337 - {
338 - state.open_forge_window(&hash);
339 - }
340 - }
341 - });
342 - });
343 -
344 - if let Some(hash) = &node.node.sample_hash {
345 - let hash = hash.clone();
346 - // M-10: gate Discovery on the analysis features each path needs.
347 - // Find Similar reads spectral_centroid / spectral_bandwidth;
348 - // Find Duplicates reads the peak-envelope fingerprint. Without
349 - // these the button "works" but always returns zero results,
350 - // which reads as a broken feature instead of a missing prereq.
351 - let has_spectral = state
352 - .detail
353 - .selected_analysis
354 - .as_ref()
355 - .is_some_and(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some());
356 - let has_fingerprint = state
357 - .detail
358 - .selected_analysis
359 - .as_ref()
360 - .is_some_and(|a| a.fingerprint.is_some());
361 - egui::CollapsingHeader::new("Discovery")
362 - .id_salt("detail_discovery_section")
363 - .default_open(true)
364 - .show(ui, |ui| {
365 - ui.horizontal(|ui| {
366 - let similar_resp = ui.add_enabled(
367 - has_spectral,
368 - egui::Button::new("Find Similar"),
369 - );
370 - let similar_resp = if has_spectral {
371 - similar_resp.on_hover_text("Find similar samples (Shift+F)")
372 - } else {
373 - similar_resp.on_disabled_hover_text(
374 - "Re-analyze this sample with spectral features enabled to find similar samples.",
375 - )
376 - };
377 - if similar_resp.clicked() {
378 - state.find_similar(&hash);
379 - }
380 - let dup_resp = ui.add_enabled(
381 - has_fingerprint,
382 - egui::Button::new("Find Duplicates"),
383 - );
384 - let dup_resp = if has_fingerprint {
385 - dup_resp.on_hover_text("Find near-duplicates (Shift+D)")
386 - } else {
387 - dup_resp.on_disabled_hover_text(
388 - "Re-analyze this sample with fingerprinting enabled to find duplicates.",
389 - )
390 - };
391 - if dup_resp.clicked() {
392 - state.find_near_duplicates(&hash);
393 - }
394 - });
395 - });
396 - }
397 - }
398 -
399 - /// Reduce a field across a multi-selection to one displayable value. Returns
400 - /// `None` when the selection is empty or the first item lacks the field (nothing
401 - /// to show), `Some(Err(()))` when the values differ or any item lacks the field
402 - /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`.
403 - pub(crate) fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
404 - where
405 - F: Fn(&T) -> Option<V>,
406 - V: PartialEq,
407 - {
408 - let mut iter = items.iter().map(&extract);
409 - let first = iter.next()??;
410 - for v in iter {
411 - match v {
412 - Some(v) if v == first => {}
413 - Some(_) => return Some(Err(())),
414 - None => return Some(Err(())),
415 - }
416 - }
417 - Some(Ok(first))
418 - }
419 -
420 - /// Draw a multi-selection summary: common metadata, union of tags, bulk-edit affordance.
421 - fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) {
422 - let nodes = state.selected_nodes();
423 - let samples: Vec<_> = nodes
424 - .iter()
425 - .filter(|n| n.node.sample_hash.is_some())
426 - .collect();
427 - let sample_count = samples.len();
428 - let folder_count = nodes.len().saturating_sub(sample_count);
429 -
430 - let heading = if folder_count == 0 {
431 - format!("{sample_count} samples selected")
432 - } else {
433 - format!("{sample_count} samples \u{00B7} {folder_count} folders selected")
434 - };
435 - ui.label(egui::RichText::new(heading).strong().size(14.0));
436 - ui.add_space(theme::space::peer());
437 -
438 - if sample_count == 0 {
439 - widgets::empty_state(
440 - ui,
441 - "No sample metadata to summarize",
442 - Some("Select one or more samples to see common fields"),
443 - None,
444 - );
445 - return;
446 - }
447 -
448 - // Common metadata: show value if uniform across the selection, otherwise "varies".
449 - ui.group(|ui| {
450 - egui::Grid::new("detail_multi_metadata")
451 - .num_columns(2)
452 - .spacing([8.0, theme::grid_row_spacing()])
453 - .show(ui, |ui| {
454 - let bpm = summarize(&samples, |n| n.bpm);
455 - ui.label(egui::RichText::new("BPM").color(theme::content_secondary()));
456 - ui.label(match bpm {
457 - Some(Ok(v)) => widgets::format_bpm(v),
458 - Some(Err(())) => "varies".to_string(),
459 - None => "\u{2014}".to_string(),
460 - });
461 - ui.end_row();
462 -
463 - let key = summarize(&samples, |n| n.musical_key.clone());
464 - ui.label(egui::RichText::new("Key").color(theme::content_secondary()));
465 - ui.label(match key {
466 - Some(Ok(v)) => v,
467 - Some(Err(())) => "varies".to_string(),
468 - None => "\u{2014}".to_string(),
469 - });
470 - ui.end_row();
471 -
472 - let dur = summarize(&samples, |n| n.duration);
473 - ui.label(egui::RichText::new("Duration").color(theme::content_secondary()));
474 - ui.label(match dur {
475 - Some(Ok(v)) => widgets::format_duration(v),
476 - Some(Err(())) => "varies".to_string(),
477 - None => "\u{2014}".to_string(),
478 - });
479 - ui.end_row();
480 - });
481 - });
482 -
483 - ui.add_space(theme::section_spacing());
484 - ui.separator();
485 - ui.add_space(theme::section_spacing() * 0.5);
486 -
487 - // Tag union with per-tag count badges.
488 - widgets::subsection_label(ui, "Tags");
489 - let mut tag_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
490 - for n in &samples {
491 - for tag in &n.tags {
492 - *tag_counts.entry(tag.clone()).or_insert(0) += 1;
493 - }
494 - }
495 - if tag_counts.is_empty() {
496 - ui.label(egui::RichText::new("No tags").color(theme::content_muted()));
497 - } else {
498 - let mut entries: Vec<_> = tag_counts.into_iter().collect();
499 - entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
500 - // Collect the hashes once so the closure that handles a badge click
Lines truncated