use egui; use crate::state::{BrowserState, CancelKind, ImportMode}; use super::super::{theme, widgets}; /// Render the accumulated import + analysis error log. Default-expanded so the /// user sees actionable errors as they accumulate (M-1); a "Hide"/"Show" toggle /// at the top-right of the section dismisses noise without losing the count. fn draw_error_log( ui: &mut egui::Ui, expanded: &mut bool, import_errors: &[crate::state::ImportFileError], analysis_errors: &[crate::state::AnalysisFileError], ) { let err_count = import_errors.len() + analysis_errors.len(); if err_count == 0 { return; } ui.add_space(theme::space::SM); ui.horizontal(|ui| { ui.label( egui::RichText::new(format!( "{err_count} error{}", if err_count == 1 { "" } else { "s" }, )) .color(theme::accent_red()), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let toggle_label = if *expanded { "Hide" } else { "Show" }; if ui .small_button(toggle_label) .on_hover_text("Toggle the error list") .clicked() { *expanded = !*expanded; } }); }); if *expanded { egui::ScrollArea::vertical() .max_height(120.0) .show(ui, |ui| { for err in import_errors { ui.label( egui::RichText::new(format!("{}: {}", err.path, err.error)) .small() .color(theme::accent_red()), ); } for err in analysis_errors { ui.label( egui::RichText::new(format!("{}: {}", err.name, err.error)) .small() .color(theme::accent_red()), ); } }); } } /// Render the rate + ETA readout below a progress bar (M-11). Reads from the /// rolling sample buffer on `state.operation_progress`; silently suppresses /// itself until the buffer has enough data to predict. fn draw_rate_and_eta( ui: &mut egui::Ui, state: &mut BrowserState, completed: usize, total: usize, noun_per_sec: &str, ) { if let Some(progress) = state.operation_progress.as_mut() { progress.record(completed); let parts: Vec = [ progress.rate().map(|r| format!("{r:.1} {noun_per_sec}")), progress.eta(completed, total), ] .into_iter() .flatten() .collect(); if !parts.is_empty() { ui.label( egui::RichText::new(parts.join(" \u{00B7} ")) .small() .color(theme::text_muted()), ); } } } /// Draw the folder import progress screen. pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) { let ctx = ui.ctx().clone(); let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) = match &state.import_mode { ImportMode::Importing { total, completed, current_name, walking, walking_count, total_bytes, loose_files, } => (*total, *completed, current_name.clone(), *walking, *walking_count, *total_bytes, *loose_files), _ => return, }; egui::CentralPanel::default().show_inside(ui, |ui| { // Keep the step rail visible during the import work (P5) — current step // is still Configure, since import is the work that step kicks off. widgets::wizard_steps(ui, super::WIZARD_STEPS, 0); ui.heading("Importing Folder..."); ui.add_space(theme::space::LG); if walking { // m-12: running file count from throttled ImportWalkProgress // events. Holds at "Scanning for audio files..." until the first // event arrives so very fast walks don't flash a zero. ui.horizontal(|ui| { ui.spinner(); if walking_count > 0 { let label = if total_bytes > 0 { format!( "Scanning for audio files... {walking_count} found ({})", widgets::format_bytes(total_bytes), ) } else { format!("Scanning for audio files... {walking_count} found") }; ui.label(label); } else { ui.label("Scanning for audio files..."); } }); } else { // Storage estimate if total_bytes > 0 { let size_label = widgets::format_bytes(total_bytes); let storage_text = if loose_files { format!("{total} files, {size_label} total (referenced in place, no copies)") } else { format!("{total} files, ~{size_label} will be duplicated into vault") }; ui.label( egui::RichText::new(storage_text) .small() .color(if loose_files { theme::accent_yellow() } else { theme::text_secondary() }), ); ui.add_space(theme::space::SM); } let progress = if total > 0 { completed as f32 / total as f32 } else { 0.0 }; let pct = (progress * 100.0) as u32; ui.add( egui::ProgressBar::new(progress) .text(format!("{pct}% \u{2014} {completed}/{total} files")), ); // Rate + ETA (M-11). draw_rate_and_eta(ui, state, completed, total, "files/sec"); ui.add_space(theme::space::MD); if !current_name.is_empty() { ui.label(format!("Importing: {current_name}")); } } // Error log: default-expanded so accumulating errors don't pile up // behind a click (M-1). Hide toggle at the top-right. draw_error_log( ui, &mut state.import_errors_expanded, &state.import_file_errors, &state.analysis_errors, ); let err_count = state.import_file_errors.len() + state.analysis_errors.len(); ui.add_space(theme::space::SECTION); ui.horizontal(|ui| { // Cancel during the walking phase is disabled with an explanatory // tooltip (M-2): cancel_import's interruption semantics for the // walker are not guaranteed, and the walk usually completes in // seconds anyway. Once walking finishes, Cancel becomes available. if walking { let _ = ui .add_enabled(false, egui::Button::new("Cancel")) .on_disabled_hover_text( "Scanning — cancel available once the scan completes.", ); } else if ui.button("Cancel").clicked() { state.cancel_import(); } if err_count > 0 && ui.button("Retry") .on_hover_text("Cancel and re-open import configuration") .clicked() { state.retry_import(); } }); }); ctx.request_repaint(); } /// Draw the cleanup (orphaned sample removal) progress screen. pub fn draw_cleanup_progress(ui: &mut egui::Ui, state: &mut BrowserState) { let ctx = ui.ctx().clone(); let (completed, total, current_name) = match &state.import_mode { ImportMode::Cleaning { completed, total, current_name, } => (*completed, *total, current_name.clone()), _ => return, }; egui::CentralPanel::default().show_inside(ui, |ui| { ui.heading("Cleaning Up Samples..."); ui.add_space(theme::space::LG); if total == 0 { ui.horizontal(|ui| { ui.spinner(); ui.label("Scanning for orphaned samples..."); }); } else { let progress = completed as f32 / total as f32; let pct = (progress * 100.0) as u32; ui.add( egui::ProgressBar::new(progress) .text(format!("{pct}% \u{2014} {completed}/{total} samples")), ); ui.add_space(theme::space::MD); if !current_name.is_empty() { ui.label(format!("Removing: {current_name}")); } } ui.add_space(theme::space::SECTION); if ui.button("Cancel").clicked() { state.cancel_cleanup(); } }); ctx.request_repaint(); } /// Draw the analysis progress screen. pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) { let ctx = ui.ctx().clone(); let (completed, total, current_name) = match &state.import_mode { ImportMode::Analyzing { completed, total, current_name, } => (*completed, *total, current_name.clone()), _ => return, }; egui::CentralPanel::default().show_inside(ui, |ui| { // Step rail stays visible on the slow Analyze screen, where orientation // matters most (P5). Analyze is step index 2. widgets::wizard_steps(ui, super::WIZARD_STEPS, 2); ui.heading("Analyzing Samples..."); ui.add_space(theme::space::LG); let progress = if total > 0 { completed as f32 / total as f32 } else { 0.0 }; let pct = (progress * 100.0) as u32; ui.add( egui::ProgressBar::new(progress) .text(format!("{pct}% \u{2014} {completed}/{total} samples")), ); // Rate + ETA (M-11). draw_rate_and_eta(ui, state, completed, total, "samples/sec"); ui.add_space(theme::space::MD); if !current_name.is_empty() { ui.label(format!("Analysing: {current_name}")); } // Error log (M-1). draw_error_log( ui, &mut state.import_errors_expanded, &state.import_file_errors, &state.analysis_errors, ); let err_count = state.import_file_errors.len() + state.analysis_errors.len(); ui.add_space(theme::space::SECTION); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { state.cancel_analysis(); } if err_count > 0 && ui.button("Retry") .on_hover_text("Cancel and restart analysis") .clicked() { state.retry_analysis(); } }); }); ctx.request_repaint(); } /// Acknowledgement screen shown after the user cancels a long-running import, /// analysis, or export. Phase-5 C-3: cancelling shouldn't drop straight to /// `None` — the user needs to know what landed vs what was discarded. pub fn draw_operation_cancelled(ui: &mut egui::Ui, state: &mut BrowserState) { let (kind, completed, total, destination) = match &state.import_mode { ImportMode::OperationCancelled { kind, completed, total, destination, } => (*kind, *completed, *total, destination.clone()), _ => return, }; let (heading, noun, follow_up) = match kind { CancelKind::Import => ( "Import cancelled", "files", "Imported files remain in the library. Re-run the import to add the rest \u{2014} duplicates will be skipped.", ), CancelKind::Analysis => ( "Analysis cancelled", "samples", "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} run analysis again to complete them.", ), CancelKind::Export => ( "Export cancelled", "files", "Files already written to the destination folder remain. A partial file for the in-progress item may also be present.", ), }; egui::CentralPanel::default().show_inside(ui, |ui| { ui.heading(heading); ui.add_space(theme::space::LG); ui.label( egui::RichText::new(format!("Stopped at {completed} of {total} {noun}.")) .strong(), ); ui.add_space(theme::space::SM); ui.label( egui::RichText::new(follow_up) .color(theme::text_secondary()), ); if let Some(dest) = destination.as_ref() { ui.add_space(theme::space::SM); ui.label( egui::RichText::new(format!("Destination: {}", dest.display())) .small() .color(theme::text_muted()), ); } ui.add_space(theme::space::SECTION); if widgets::primary_button(ui, "Done").clicked() { state.import_mode = ImportMode::None; } }); }