//! Right detail panel: waveform display, metadata grid, tags, and copy-path button. use egui; use crate::state::BrowserState; use crate::waveform; use super::theme; use super::widgets; /// Draw the detail panel content for the currently selected sample. pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { if state.selection.count() > 1 { draw_multi_summary(ui, state); return; } let node = match state.selected_node() { Some(n) => n, None => { widgets::empty_state(ui, "Select a sample", None, None); return; } }; // Waveform if let Some(ref waveform_data) = state.selected_waveform { // Compute playback position as a 0.0–1.0 fraction for the waveform cursor. // Only valid when the currently-playing hash matches this node's hash. let playback_pos = if state.previewing_hash.as_deref() == node.node.sample_hash.as_deref() { let playback = state.shared.preview.lock(); if playback.playing { if let Some(ref buf) = playback.buffer { // During streaming, the buffer grows so use the metadata estimate // for a stable cursor. Fall back to current buffer size otherwise. let total_frames = if playback.streaming { playback.total_frames_estimate.unwrap_or(playback.decoded_frames) } else { buf.data.len() / 2 }; if total_frames > 0 { Some((playback.position_frac / total_frames as f64) as f32) } else { None } } else { None } } else { None } } else { None }; let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0); // Hover indicator: paint a vertical accent_blue line at the cursor X // and a time label above it so the user can see where a click-to-seek // would land before committing. if resp.hovered() && let Some(pos) = resp.hover_pos() { let rect = resp.rect; let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); let total_secs = waveform_data.duration as f32; let cursor_secs = normalized * total_secs; ui.painter().line_segment( [ egui::pos2(pos.x, rect.top()), egui::pos2(pos.x, rect.bottom()), ], egui::Stroke::new(1.0, theme::accent_blue()), ); let label = format!( "{:.0}:{:02.0}", (cursor_secs / 60.0).floor(), cursor_secs % 60.0, ); ui.painter().text( egui::pos2(pos.x, rect.top() - 2.0), egui::Align2::CENTER_BOTTOM, label, egui::FontId::proportional(10.0), theme::text_secondary(), ); } // Click-to-seek: map the click's X position to a 0.0–1.0 fraction // within the waveform rect, then set the playback cursor to that frame. if resp.clicked() && let Some(pos) = resp.interact_pointer_pos() { let rect = resp.rect; let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); if let Some(hash) = &node.node.sample_hash && state.previewing_hash.as_deref() == Some(hash) { let mut playback = state.shared.preview.lock(); if let Some(ref buf) = playback.buffer { let total_frames = if playback.streaming { playback.total_frames_estimate.unwrap_or(playback.decoded_frames) } else { buf.data.len() / 2 }; playback.position_frac = (normalized as f64 * total_frames as f64) .min((playback.decoded_frames.max(1) - 1) as f64); } } } ui.add_space(theme::section_spacing()); } // Sample name ui.label(egui::RichText::new(&node.node.name).strong().size(14.0)); ui.add_space(theme::space::MD); // Analysis metadata grid if let Some(ref analysis) = state.selected_analysis { egui::CollapsingHeader::new("Metadata") .id_salt("detail_metadata_section") .default_open(true) .show(ui, |ui| { egui::Grid::new("detail_metadata") .num_columns(2) .spacing([8.0, theme::grid_row_spacing()]) .show(ui, |ui| { ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); ui.label(widgets::format_duration(analysis.duration)); ui.end_row(); if let Some(bpm) = analysis.bpm { ui.label(egui::RichText::new("BPM").color(theme::text_secondary())); ui.label(widgets::format_bpm(bpm)); ui.end_row(); } if let Some(ref key) = analysis.musical_key { ui.label(egui::RichText::new("Key").color(theme::text_secondary())); ui.label(key); ui.end_row(); } if let Some(ref class) = analysis.classification { ui.label(egui::RichText::new("Class").color(theme::text_secondary())); widgets::classification_badge(ui, class.as_str()); ui.end_row(); } ui.label(egui::RichText::new("Sample Rate").color(theme::text_secondary())); ui.label(format!("{} Hz", analysis.sample_rate)); ui.end_row(); ui.label(egui::RichText::new("Channels").color(theme::text_secondary())); ui.label(format!("{}", analysis.channels)); ui.end_row(); if let Some(peak) = analysis.peak_db { ui.label(egui::RichText::new("Peak").color(theme::text_secondary())); ui.label(format!("{:.1} dB", peak)); ui.end_row(); } if let Some(rms) = analysis.rms_db { ui.label(egui::RichText::new("RMS").color(theme::text_secondary())); ui.label(format!("{:.1} dB", rms)); ui.end_row(); } if let Some(lufs) = analysis.lufs { ui.label(egui::RichText::new("LUFS").color(theme::text_secondary())); ui.label(format!("{:.1}", lufs)); ui.end_row(); } if let Some(is_loop) = analysis.is_loop { ui.label(egui::RichText::new("Loop").color(theme::text_secondary())); ui.label(if is_loop { "Yes" } else { "No" }); ui.end_row(); } }); }); } ui.add_space(theme::section_spacing()); egui::CollapsingHeader::new("Tags") .id_salt("detail_tags_section") .default_open(true) .show(ui, |ui| { if state.selected_tags.is_empty() { ui.label(egui::RichText::new("No tags").color(theme::text_muted())); } else { ui.horizontal_wrapped(|ui| { let tags = state.selected_tags.clone(); for tag in tags.iter() { if widgets::tag_chip_removable(ui, tag, true) { // Remove tag and push an undoable entry so Cmd+Z restores it. if let Some(ref hash) = node.node.sample_hash { let hash_str = hash.to_string(); if state.backend.remove_tag(hash, tag).is_ok() { state.push_undo(crate::state::UndoOp::TagRemove { hash: hash_str, tag: tag.clone(), }); state.status = format!("Removed tag \"{tag}\""); state.refresh_selected_tags(); } } } } }); } // Tag input ui.horizontal(|ui| { let resp = ui.add( egui::TextEdit::singleline(&mut state.tag_input) .hint_text("Add tag (use dots: genre.house)") .desired_width(ui.available_width() - 40.0), ); // Honor the Tab-from-table shortcut: focus the tag input on this frame. if state.focus_tag_input { resp.request_focus(); state.focus_tag_input = false; } if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) || ui.small_button("+").on_hover_text("Add tag").clicked() { let tag = state.tag_input.trim().to_string(); if !tag.is_empty() && let Some(ref hash) = node.node.sample_hash { if audiofiles_core::tags::validate_tag(&tag).is_ok() { let _ = state.backend.add_tag(hash, &tag); state.tag_input.clear(); state.refresh_selected_tags(); } else { state.status = format!("Invalid tag: {tag}"); } } } }); // Tag suggestions based on classification. Per-classification dismissals // let the user say "I never tag kicks with `percussion`" once and have // the suggestion stop appearing on every future kick. if let Some(ref analysis) = state.selected_analysis && let Some(ref class) = analysis.classification { let class_str = class.to_string(); let dismissed_for_class = state .dismissed_suggestions .get(&class_str) .cloned() .unwrap_or_default(); let suggestions: Vec<&'static str> = classification_tag_suggestions(&class_str, &state.selected_tags) .into_iter() .filter(|s| !dismissed_for_class.iter().any(|d| d == s)) .collect(); if !suggestions.is_empty() { ui.add_space(theme::space::SM); ui.horizontal_wrapped(|ui| { ui.label( egui::RichText::new(format!("Suggest (from {class_str}):")) .small() .color(theme::text_muted()), ); for sug in &suggestions { if ui .small_button( egui::RichText::new(format!("+{sug}")) .small() .color(theme::accent_blue()), ) .on_hover_text(format!("Add tag: {sug}")) .clicked() && let Some(ref hash) = node.node.sample_hash { let _ = state.backend.add_tag(hash, sug); state.refresh_selected_tags(); } // Painted X (two crossed line_segments) — matches the // Phase 4 M-8 X-icon precedent in instrument_panel.rs // rather than a literal "x" glyph. Muted stroke since // this is a secondary dismiss, not a danger action. let icon_size = egui::vec2(14.0, 14.0); let (icon_rect, icon_resp) = ui.allocate_exact_size(icon_size, egui::Sense::click()); let icon_resp = icon_resp.on_hover_text(format!( "Never suggest \"{sug}\" on {class_str} samples again" )); let pad = 3.5; let p1 = icon_rect.min + egui::vec2(pad, pad); let p2 = icon_rect.max - egui::vec2(pad, pad); let p3 = egui::pos2(icon_rect.min.x + pad, icon_rect.max.y - pad); let p4 = egui::pos2(icon_rect.max.x - pad, icon_rect.min.y + pad); let stroke_color = if icon_resp.hovered() { theme::text_secondary() } else { theme::text_muted() }; let stroke = egui::Stroke::new(1.2, stroke_color); let painter = ui.painter(); painter.line_segment([p1, p2], stroke); painter.line_segment([p3, p4], stroke); if icon_resp.clicked() { state.dismiss_suggestion(&class_str, sug); } } }); } // M-1: inline Undo for the most recent dismiss. Visible for ~5s // after the click so the affordance is at the locus of the action. // Older dismissals still recoverable via Settings → Reset // suggestions; this is just the fast-path for a stray click. const UNDO_WINDOW: f32 = 5.0; let show_undo = state .last_dismissed_suggestion .as_ref() .filter(|(c, _, _)| c == &class_str) .map(|(_, _, at)| at.elapsed().as_secs_f32() < UNDO_WINDOW); if show_undo == Some(true) { let (_, tag, _) = state .last_dismissed_suggestion .as_ref() .expect("checked Some above") .clone(); ui.add_space(theme::space::XS); ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Muted \"{tag}\" for {class_str}.")) .small() .color(theme::text_muted()), ); if ui .link( egui::RichText::new("Undo") .small() .color(theme::accent_blue()), ) .clicked() { state.undo_last_dismissal(); } }); // Keep repainting so the affordance fades when the timer // crosses 5s — without this the user could leave focus on a // stale link. ui.ctx().request_repaint(); } } }); // end of Tags CollapsingHeader egui::CollapsingHeader::new("Actions") .id_salt("detail_actions_section") .default_open(true) .show(ui, |ui| { ui.horizontal(|ui| { if ui.button("Copy Path").on_hover_text("Copy file path to clipboard").clicked() && let Some(path) = state.selected_sample_path() { state.status = format!("Copied: {path}"); ui.ctx().copy_text(path); } if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() { state.open_edit_window(&hash); } if ui.button("Forge").on_hover_text("Chop / conform / batch (F)").clicked() { state.open_forge_window(&hash); } } }); }); if let Some(hash) = &node.node.sample_hash { let hash = hash.clone(); // M-10: gate Discovery on the analysis features each path needs. // Find Similar reads spectral_centroid / spectral_bandwidth; // Find Duplicates reads the peak-envelope fingerprint. Without // these the button "works" but always returns zero results, // which reads as a broken feature instead of a missing prereq. let has_spectral = state .selected_analysis .as_ref() .map(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some()) .unwrap_or(false); let has_fingerprint = state .selected_analysis .as_ref() .map(|a| a.fingerprint.is_some()) .unwrap_or(false); egui::CollapsingHeader::new("Discovery") .id_salt("detail_discovery_section") .default_open(true) .show(ui, |ui| { ui.horizontal(|ui| { let similar_resp = ui.add_enabled( has_spectral, egui::Button::new("Find Similar"), ); let similar_resp = if has_spectral { similar_resp.on_hover_text("Find similar samples (Shift+F)") } else { similar_resp.on_disabled_hover_text( "Re-analyze this sample with spectral features enabled to find similar samples.", ) }; if similar_resp.clicked() { state.find_similar(&hash); } let dup_resp = ui.add_enabled( has_fingerprint, egui::Button::new("Find Duplicates"), ); let dup_resp = if has_fingerprint { dup_resp.on_hover_text("Find near-duplicates (Shift+D)") } else { dup_resp.on_disabled_hover_text( "Re-analyze this sample with fingerprinting enabled to find duplicates.", ) }; if dup_resp.clicked() { state.find_near_duplicates(&hash); } }); }); } } /// Draw a multi-selection summary: common metadata, union of tags, bulk-edit affordance. fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) { let nodes = state.selected_nodes(); let samples: Vec<_> = nodes .iter() .filter(|n| n.node.sample_hash.is_some()) .collect(); let sample_count = samples.len(); let folder_count = nodes.len().saturating_sub(sample_count); let heading = if folder_count == 0 { format!("{sample_count} samples selected") } else { format!( "{sample_count} samples \u{00B7} {folder_count} folders selected", ) }; ui.label(egui::RichText::new(heading).strong().size(14.0)); ui.add_space(theme::space::MD); if sample_count == 0 { widgets::empty_state( ui, "No sample metadata to summarize", Some("Select one or more samples to see common fields"), None, ); return; } // Common metadata: show value if uniform across the selection, otherwise "varies". fn summarize(items: &[T], extract: F) -> Option> where F: Fn(&T) -> Option, V: PartialEq, { let mut iter = items.iter().map(&extract); let first = iter.next()??; for v in iter { match v { Some(v) if v == first => continue, Some(_) => return Some(Err(())), None => return Some(Err(())), } } Some(Ok(first)) } ui.group(|ui| { egui::Grid::new("detail_multi_metadata") .num_columns(2) .spacing([8.0, theme::grid_row_spacing()]) .show(ui, |ui| { let bpm = summarize(&samples, |n| n.bpm); ui.label(egui::RichText::new("BPM").color(theme::text_secondary())); ui.label(match bpm { Some(Ok(v)) => widgets::format_bpm(v), Some(Err(())) => "varies".to_string(), None => "\u{2014}".to_string(), }); ui.end_row(); let key = summarize(&samples, |n| n.musical_key.clone()); ui.label(egui::RichText::new("Key").color(theme::text_secondary())); ui.label(match key { Some(Ok(v)) => v, Some(Err(())) => "varies".to_string(), None => "\u{2014}".to_string(), }); ui.end_row(); let class = summarize(&samples, |n| n.classification.clone()); ui.label(egui::RichText::new("Class").color(theme::text_secondary())); match class { Some(Ok(v)) => widgets::classification_badge(ui, &v), Some(Err(())) => { ui.label("varies"); } None => { ui.label("\u{2014}"); } } ui.end_row(); let dur = summarize(&samples, |n| n.duration); ui.label(egui::RichText::new("Duration").color(theme::text_secondary())); ui.label(match dur { Some(Ok(v)) => widgets::format_duration(v), Some(Err(())) => "varies".to_string(), None => "\u{2014}".to_string(), }); ui.end_row(); }); }); ui.add_space(theme::section_spacing()); ui.separator(); ui.add_space(theme::section_spacing() * 0.5); // Tag union with per-tag count badges. widgets::subsection_label(ui, "Tags"); let mut tag_counts: std::collections::HashMap = std::collections::HashMap::new(); for n in &samples { for tag in &n.tags { *tag_counts.entry(tag.clone()).or_insert(0) += 1; } } if tag_counts.is_empty() { ui.label(egui::RichText::new("No tags").color(theme::text_muted())); } else { let mut entries: Vec<_> = tag_counts.into_iter().collect(); entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); // Collect the hashes once so the closure that handles a badge click // doesn't need to re-walk the selection. `samples` borrows from `nodes` // which borrows from state — capture by value here so we can mutate // state below. let all_hashes: Vec = samples .iter() .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) .collect(); // M-11: actionable partial-coverage badges. Right-click any badge to // apply / remove the tag across the selection. Full-coverage badges // still render but expose only "Remove from all" (no Apply needed). let mut pending_apply: Option<(String, Vec)> = None; let mut pending_remove: Option<(String, Vec)> = None; ui.horizontal_wrapped(|ui| { for (tag, count) in entries { let full = count == sample_count; let label = if full { tag.clone() } else { format!("{tag} ({count}/{sample_count})") }; let hover = if full { format!("\"{tag}\" \u{2014} on all {sample_count}. Right-click to remove from all.") } else { let missing = sample_count - count; format!( "\"{tag}\" \u{2014} on {count} of {sample_count}. Right-click to apply to remaining {missing} or remove from {count}." ) }; let resp = ui .label( egui::RichText::new(label) .small() .color(theme::accent_blue()), ) .on_hover_text(hover); resp.context_menu(|ui| { if !full { let missing = sample_count - count; if ui .button(format!("Apply to remaining ({missing})")) .clicked() { let targets: Vec = samples .iter() .filter(|n| !n.tags.iter().any(|t| t == &tag)) .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) .collect(); pending_apply = Some((tag.clone(), targets)); ui.close(); } } let remove_label = if full { format!("Remove from all ({count})") } else { format!("Remove from {count}") }; if widgets::danger_button(ui, &remove_label).clicked() { let targets: Vec = samples .iter() .filter(|n| n.tags.iter().any(|t| t == &tag)) .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string())) .collect(); pending_remove = Some((tag.clone(), targets)); ui.close(); } }); } }); let _ = all_hashes; // currently unused; reserved for future Apply-to-all path. if let Some((tag, targets)) = pending_apply { state.apply_tag_to_hashes(&tag, &targets); } else if let Some((tag, targets)) = pending_remove { state.remove_tag_from_hashes(&tag, &targets); } } ui.add_space(theme::section_spacing()); ui.separator(); ui.add_space(theme::section_spacing() * 0.5); if widgets::primary_button(ui, "Edit as bulk") .on_hover_text("Add or remove a tag across the entire selection") .clicked() { state.open_bulk_tag_modal(); } } /// Suggest tags based on the sample's classification. Excludes tags already applied. fn classification_tag_suggestions(classification: &str, existing_tags: &[String]) -> Vec<&'static str> { let candidates: &[&str] = match classification { "kick" => &["drums.kick", "percussion", "one-shot"], "snare" => &["drums.snare", "percussion", "one-shot"], "hihat" => &["drums.hihat", "percussion", "one-shot"], "cymbal" => &["drums.cymbal", "percussion", "one-shot"], "percussion" => &["percussion", "one-shot"], "bass" => &["bass", "synth.bass"], "vocal" => &["vocal"], "synth" => &["synth", "melodic"], "pad" => &["synth.pad", "melodic", "texture"], "fx" => &["fx", "texture"], "noise" => &["noise", "texture"], "music" => &["loop", "melodic"], "ambience" => &["ambience", "texture", "field-recording"], "impact" => &["fx.impact", "one-shot"], "foley" => &["foley", "field-recording"], "texture" => &["texture"], _ => &[], }; candidates .iter() .filter(|&&tag| !existing_tags.iter().any(|t| t == tag || t.starts_with(&format!("{tag}.")))) .copied() .collect() }