//! Floating sample editor window: all edit controls visible simultaneously. use egui; use crate::state::{BrowserState, EditResultMode}; use crate::waveform; use audiofiles_core::edit::FadeCurve; use super::theme; use super::widgets; /// Draw the floating sample editor window. Call from the overlay layer. pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) { let mut open = state.edit.show_window; widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| { // In-progress bar. Drawn at the top while an edit applies; the body // below stays rendered (every section greys itself out via its own // `in_progress` disabled flag) so the user keeps the waveform and // controls for spatial reference instead of the panel collapsing to a // lone spinner. if state.edit.in_progress { ui.horizontal(|ui| { ui.spinner(); ui.label("Applying edit..."); // M-11: best-effort cancel. Signals the worker and clears // in_progress so the UI is interactive even if the worker // is mid-write — the cancel is advisory, not synchronous. if ui.button("Cancel").clicked() { state.cancel_edit_operation(); } }); ui.separator(); } // Result prompt overlay if state.edit.result_prompt { draw_result_prompt(ui, state); return; } let hash = match &state.edit.hash { Some(h) => h.clone(), None => return, }; draw_waveform_section(ui, state, &hash); draw_info_line(ui, state); draw_transport_section(ui, state, &hash); ui.separator(); draw_trim_section(ui, state); ui.separator(); draw_levels_section(ui, state); ui.separator(); draw_transform_section(ui, state); ui.separator(); draw_silence_section(ui, state); ui.separator(); draw_result_section(ui, state); // Batch edit section (shown when multiple samples selected) ui.separator(); draw_batch_section(ui, state); }); state.edit.show_window = open; } /// Waveform display with playback cursor and click-to-seek. fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) { if let Some(ref waveform_data) = state.selected_waveform { // Show the playhead whenever this sample is the active preview and a // buffer is loaded — including while paused, so the user can see where // playback sits before auditioning a trim. let playback_pos = if state.previewing_hash.as_deref() == Some(hash) { let playback = state.shared.preview.lock(); playback.buffer.as_ref().and_then(|buf| { let total_frames = if playback.streaming { playback.total_frames_estimate.unwrap_or(playback.decoded_frames) } else { buf.data.len() / 2 }; (total_frames > 0) .then(|| (playback.position_frac / total_frames as f64) as f32) }) } else { None }; // p-6: 120px matches the detail panel waveform; the edit context wants // the larger surface for precise trim work (was 80px). let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0); // C-1 part 1: paint the trim preview overlay. Regions outside the // current [trim_start, trim_end] are dimmed so the user sees what // *will be removed* before clicking Trim. Slider edges = preview // edges. Updates live as the user drags. let trim_start = state.edit.trim_start; let trim_end = state.edit.trim_end; if trim_start > 0.001 || trim_end < 0.999 { let rect = resp.rect; let painter = ui.painter_at(rect); let overlay = theme::trim_mute_overlay(); if trim_start > 0.0 { let x_end = rect.left() + rect.width() * trim_start; painter.rect_filled( egui::Rect::from_min_max(rect.min, egui::pos2(x_end, rect.max.y)), 0.0, overlay, ); } if trim_end < 1.0 { let x_start = rect.left() + rect.width() * trim_end; painter.rect_filled( egui::Rect::from_min_max(egui::pos2(x_start, rect.min.y), rect.max), 0.0, overlay, ); } } // Draggable trim handles drawn over the waveform. These are the primary // way to set the cut region; the Start/End sliders below are the numeric // path. Handles are always present (even at the 0.0/1.0 default) so the // user can grab either edge directly. { let rect = resp.rect; let (new_start, start_dragged) = trim_handle(ui, rect, state.edit.trim_start, "edit_trim_handle_start"); let (new_end, end_dragged) = trim_handle(ui, rect, state.edit.trim_end, "edit_trim_handle_end"); // Only the handle the user is actually dragging moves; it clamps // against the other so the untouched handle never jumps (and a // minimum region is preserved). const MIN_GAP: f32 = 0.001; if start_dragged { state.edit.trim_start = new_start.clamp(0.0, state.edit.trim_end - MIN_GAP); } if end_dragged { state.edit.trim_end = new_end.clamp(state.edit.trim_start + MIN_GAP, 1.0); } } // Click-to-seek. If this sample isn't the active preview yet, start it // first so a click in the editor auditions from the clicked point — // previously the click was a silent no-op until preview was started // elsewhere. 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 state.previewing_hash.as_deref() != Some(hash) { state.trigger_preview(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); } } } } /// Transport row: Play/Pause/Stop for the sample being edited, independent of /// the main file-list selection so the user can audition before committing a /// destructive edit. fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) { let is_current = state.previewing_hash.as_deref() == Some(hash); let playing = is_current && state.shared.preview.lock().playing; ui.horizontal(|ui| { if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() { if is_current { // Toggle play/pause on the already-loaded buffer. let mut pb = state.shared.preview.lock(); pb.playing = !pb.playing; } else { state.trigger_preview(hash); } } if widgets::secondary_button(ui, "Stop").clicked() { state.stop_preview(); } }); } /// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1). /// Returns `(new_frac, dragged)`. The hit area is wider than the painted line /// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on /// hover, and the line thickens while hovered or dragged for tactile feedback. fn trim_handle(ui: &mut egui::Ui, rect: egui::Rect, frac: f32, id_salt: &str) -> (f32, bool) { let frac = frac.clamp(0.0, 1.0); let x = rect.left() + rect.width() * frac; let hit = egui::Rect::from_min_max( egui::pos2(x - 4.0, rect.top()), egui::pos2(x + 4.0, rect.bottom()), ); let resp = ui.interact(hit, ui.id().with(id_salt), egui::Sense::drag()); let active = resp.hovered() || resp.dragged(); if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } let mut new_frac = frac; if resp.dragged() && let Some(p) = resp.interact_pointer_pos() { new_frac = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0); } let width = if active { 2.5 } else { 1.0 }; ui.painter_at(rect).line_segment( [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], egui::Stroke::new(width, theme::accent_yellow()), ); (new_frac, resp.dragged()) } /// Info line: name, sample rate, duration, peak dB. fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) { if let Some(ref analysis) = state.selected_analysis { let name = state.selected_node() .map(|n| n.node.name.clone()) .unwrap_or_default(); let duration = analysis.duration; let sr = analysis.sample_rate; // Only append the peak segment (and its separator) when a value exists, // so a missing peak_db doesn't leave a dangling " \u{00B7} ". let peak_suffix = analysis.peak_db .map(|p| format!(" \u{00B7} {:.1} dBFS", p)) .unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(egui::RichText::new(&name).strong().size(12.0)); ui.label( egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s{}", sr, duration, peak_suffix)) .color(theme::text_muted()) .size(11.0), ); }); ui.add_space(theme::space::XS); } } /// Trim section with start/end sliders. fn draw_trim_section(ui: &mut egui::Ui, state: &mut BrowserState) { let disabled = state.edit.in_progress || state.edit.hash.is_none(); ui.label(egui::RichText::new("Trim").strong()); let sample_rate = state.selected_analysis.as_ref() .map(|a| a.sample_rate) .unwrap_or(44100); let total = state.edit.total_frames; let start_time = state.edit.trim_start as f64 * total as f64 / sample_rate as f64; let end_time = state.edit.trim_end as f64 * total as f64 / sample_rate as f64; ui.horizontal(|ui| { ui.label("Start:"); ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_start, 0.0..=1.0).show_value(false)); ui.label(egui::RichText::new(format!("{:.3}s", start_time)).color(theme::text_muted())); }); ui.horizontal(|ui| { ui.label("End:"); ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.trim_end, 0.0..=1.0).show_value(false)); ui.label(egui::RichText::new(format!("{:.3}s", end_time)).color(theme::text_muted())); }); // Clamp start < end if state.edit.trim_start >= state.edit.trim_end { state.edit.trim_start = (state.edit.trim_end - 0.001).max(0.0); } ui.horizontal(|ui| { if ui.add_enabled(!disabled, egui::Button::new("Trim")).clicked() { state.apply_edit_trim(); } }); } /// Levels section: gain and normalize. fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) { let disabled = state.edit.in_progress || state.edit.hash.is_none(); ui.label(egui::RichText::new("Levels").strong()); // Current peak display + gain clipping warning let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db); if let Some(peak) = current_peak { let predicted = peak + state.edit.gain_db; if predicted > 0.0 { ui.colored_label( theme::accent_red(), format!("Peak: {:.1} dB \u{2192} {:.1} dB (clips!)", peak, predicted), ); } } // Gain ui.horizontal(|ui| { ui.label("Gain:"); ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB")); if ui.add_enabled(!disabled, egui::Button::new("Apply gain")).clicked() { state.apply_edit_gain(); } }); // Normalize ui.horizontal(|ui| { ui.label("Normalize:"); // M-13: switching mode resets the target to the canonical default for // the new mode (Peak: -1.0 dBFS, LUFS: -14.0 LUFS). The carried-over // value is meaningless across modes, so snap to a sane starting point. if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.norm_peak, "Peak")).clicked() { if !state.edit.norm_peak { state.edit.norm_target = -1.0; } state.edit.norm_peak = true; } if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.norm_peak, "LUFS")).clicked() { if state.edit.norm_peak { state.edit.norm_target = -14.0; } state.edit.norm_peak = false; } }); let norm_range = if state.edit.norm_peak { -24.0..=0.0 } else { -24.0..=-6.0 }; let norm_suffix = if state.edit.norm_peak { " dBFS" } else { " LUFS" }; ui.horizontal(|ui| { ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix)); if ui.add_enabled(!disabled, egui::Button::new("Normalize")).clicked() { state.apply_edit_normalize(); } }); } /// Transform section: reverse and fade. fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) { let disabled = state.edit.in_progress || state.edit.hash.is_none(); ui.label(egui::RichText::new("Transform").strong()); // Reverse if ui.add_enabled(!disabled, egui::Button::new("Reverse")).clicked() { state.apply_edit_reverse(); } // Fade ui.horizontal(|ui| { ui.label("Fade:"); if ui.add_enabled(!disabled, egui::RadioButton::new(state.edit.fade_in, "In")).clicked() { state.edit.fade_in = true; } if ui.add_enabled(!disabled, egui::RadioButton::new(!state.edit.fade_in, "Out")).clicked() { state.edit.fade_in = false; } }); ui.horizontal(|ui| { // m-8: cap raised from 2000 to 10000 ms. Long pads / textures can want // multi-second fades; the old 2-second ceiling was invisible until hit. ui.add_enabled( !disabled, egui::Slider::new(&mut state.edit.fade_duration_ms, 10.0..=10000.0).suffix(" ms"), ) .on_hover_text("Maximum fade duration 10s"); egui::ComboBox::from_id_salt("edit_fade_curve") .selected_text(match state.edit.fade_curve { FadeCurve::Linear => "Linear", FadeCurve::Logarithmic => "Log", FadeCurve::SCurve => "S-Curve", }) .width(70.0) .show_ui(ui, |ui| { ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Linear, "Linear"); ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log"); ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve"); }); if ui.add_enabled(!disabled, egui::Button::new("Apply fade")).clicked() { state.apply_edit_fade(); } }); } /// Silence section: insert or remove silence. fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { let disabled = state.edit.in_progress || state.edit.hash.is_none(); ui.label(egui::RichText::new("Silence").strong()); // m-9: clamp Insert/Remove positions to [0, sample_duration_ms] when the // sample's analysis duration is known. Falls back to the previous unbounded // range only if duration is missing (un-analyzed sample). Prevents the // silent-failure / undefined-behaviour case where positions exceed length. let duration_ms_cap = state .selected_analysis .as_ref() .map(|a| a.duration * 1000.0) .unwrap_or(f64::MAX); // Insert silence ui.horizontal(|ui| { ui.label("Insert at:"); ui.add_enabled( !disabled, egui::DragValue::new(&mut state.edit.silence_position_ms) .speed(10.0) .range(0.0..=duration_ms_cap) .suffix(" ms"), ); ui.label("Duration:"); ui.add_enabled( !disabled, egui::DragValue::new(&mut state.edit.silence_duration_ms) .speed(10.0) .range(1.0..=60000.0) .suffix(" ms"), ); if ui.add_enabled(!disabled, egui::Button::new("Insert")).clicked() { state.apply_edit_insert_silence(); } }); // Remove range ui.horizontal(|ui| { ui.label("Remove from:"); ui.add_enabled( !disabled, egui::DragValue::new(&mut state.edit.remove_start_ms) .speed(10.0) .range(0.0..=duration_ms_cap) .suffix(" ms"), ); ui.label("to:"); ui.add_enabled( !disabled, egui::DragValue::new(&mut state.edit.remove_end_ms) .speed(10.0) .range(0.0..=duration_ms_cap) .suffix(" ms"), ); if ui.add_enabled(!disabled, egui::Button::new("Remove")).clicked() { state.apply_edit_remove_range(); } }); } /// Batch edit section: apply operations to all selected samples. fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) { let selected_count = state.selected_sample_hashes().len(); if selected_count < 2 { return; } // m-13: heading is plain strong (matches other section headers). The // batch-vs-single distinction moves to a small muted badge so adjacent // sections stay visually balanced. ui.horizontal(|ui| { ui.label(egui::RichText::new("Batch Edit").strong()); ui.label( egui::RichText::new(format!("Batch \u{00B7} {} samples", selected_count)) .small() .color(theme::text_muted()), ); }); ui.label( egui::RichText::new("Apply to all selected samples at once") .small() .color(theme::text_muted()), ); ui.add_space(theme::space::SM); // M-14: bake the panel's current slider values into the button labels so // the broadcast nature is explicit. Removes the silent-piggyback footgun // where the user couldn't tell what value the batch button would use. // Hover hints are dropped — the label now carries the value. let norm_target = state.edit.norm_target; let gain_db = state.edit.gain_db; ui.horizontal(|ui| { if ui .button(format!( "Normalize {} samples to {:.1} dBFS", selected_count, norm_target )) .clicked() { state.batch_normalize_peak(norm_target); } if ui .button(format!( "Normalize {} samples to {:.1} LUFS", selected_count, norm_target )) .clicked() { state.batch_normalize_lufs(norm_target); } }); ui.horizontal(|ui| { if ui .button(format!("Apply {:.1} dB to {} samples", gain_db, selected_count)) .clicked() { state.batch_gain(gain_db); } if ui.button("Reverse").clicked() { // m-16: gate large-batch Reverse behind a confirm modal. Single- // sample Reverse is its own undo (click again), but on N samples // the "click again to undo" trick requires remembering it ran in // the first place. Threshold 10 keeps small selections frictionless. if selected_count > 10 { state.pending_confirm = Some( crate::state::ConfirmAction::ReverseSamples { count: selected_count }, ); } else { state.batch_reverse(); } } }); } /// Result mode section: replace original vs create sibling. fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { ui.label(egui::RichText::new("Result").strong()); let mut mode = state.edit.result_mode; ui.horizontal(|ui| { if ui.radio_value(&mut mode, Some(EditResultMode::Replace), "Replace original").changed() && let Some(m) = mode { state.set_edit_result_mode(m); } if ui.radio_value(&mut mode, Some(EditResultMode::Sibling), "Create sibling").changed() && let Some(m) = mode { state.set_edit_result_mode(m); } }); // Replace mode advisory. The previous "no in-app undo" copy was retired // when the inline Undo affordance landed (C-1 part 2). The hint still // points the user at Create sibling as the non-destructive default for // workflows that want to keep the original in the VFS. if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) { // Promoted from small yellow footnote to a framed warning banner — the // consequence (original removed from the vault) is important enough that // the footnote style under-sold it. widgets::warning_banner( ui, "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.", ); } // C-1 part 2: inline Undo for the most recent edit. The content store is // content-addressed so the original sample blob is preserved on Replace — // Undo only walks back the VFS work. Times out after 10s to avoid // surprising the user with a stale affordance after they've moved on. const UNDO_TIMEOUT_SECS: f32 = 10.0; let undo_expired = state .edit .last_undo .as_ref() .map(|e| e.created_at.elapsed().as_secs_f32() > UNDO_TIMEOUT_SECS) .unwrap_or(false); if undo_expired { state.edit.last_undo = None; } if let Some(ref entry) = state.edit.last_undo { let op_label = entry.op_name.clone(); let remaining = std::time::Duration::from_secs_f32(UNDO_TIMEOUT_SECS) .saturating_sub(entry.created_at.elapsed()); // Wake the UI when the affordance is due to expire so it disappears // even if the user isn't interacting with the panel. ui.ctx().request_repaint_after(remaining); ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Last edit: {op_label}")) .small() .color(theme::text_muted()), ); if ui.small_button("Undo").clicked() { state.undo_last_edit(); } }); } } /// Draw the "Replace or Create Sibling?" prompt after first edit. fn draw_result_prompt(ui: &mut egui::Ui, state: &mut BrowserState) { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.heading("Edit Result"); ui.separator(); ui.label("How should the edited sample be handled?"); ui.add_space(theme::space::MD); // m-10: initialise from the persistent result_mode so the checkbox // reflects whether the user has already locked in a default. The // Result section radios are the source of truth; this checkbox mirrors // their state for visibility, then writes through on submit. let mut remember = state.edit.result_mode.is_some(); ui.checkbox(&mut remember, "Remember my choice"); ui.add_space(theme::space::SM); ui.horizontal(|ui| { if ui.button("Replace Original").clicked() { state.confirm_edit_result(EditResultMode::Replace, remember); } if ui.button("Create Sibling").clicked() { state.confirm_edit_result(EditResultMode::Sibling, remember); } // M-12: third option lets the user back out of the prompt without // committing the edit. Drops the pending result file. if ui.button("Discard edit").clicked() { state.discard_edit_result(); } }); }); }