Skip to main content

mcraw_tui/
app.rs

1use anyhow::Result;
2use crossterm::{
3    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
4    event::{Event, KeyEventKind, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture},
5};
6use percent_encoding::percent_decode_str;
7use ratatui::backend::CrosstermBackend;
8use std::cell::Cell;
9use std::path::PathBuf;
10use std::sync::mpsc;
11use std::time::{Duration, Instant};
12use tokio::time;
13
14use crate::cli::{Cli, CliCommands, ResolvedCli};
15use crate::color::{ColorSpace, TransferFunction};
16use crate::export::{
17    Av1Profile, CodecFamily, DnxhrProfile, H264Profile, HevcProfile,
18    ProResProfile, RateControl, Vp9Profile,
19};
20use crate::hardware::probe_hardware;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use crate::decoder::Decoder;
24use crate::encoder::{EncodeJob, EncodeStatus, Encoder, OutputFormat};
25use crate::file::McrawFileInfo;
26use crate::file_browser::FileBrowser;
27use crate::preset::ExportPreset;
28use crate::stats::PipelineStats;
29
30/// Braille spinner frames for the rendering indicator (500ms cycle at 50ms/tick).
31pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
32use crate::ui::{self, ClickAction};
33
34// ---------------------------------------------------------------------------
35// Data types for the media pool / queue workflow
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone)]
39pub struct ImportedFile {
40    pub path: String,
41    pub info: McrawFileInfo,
42    pub selected: bool,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum QueueStatus {
47    Waiting,
48    Rendering,
49    Completed,
50    Failed(String),
51}
52
53#[derive(Debug, Clone)]
54pub struct QueuedFile {
55    pub path: String,
56    pub info: McrawFileInfo,
57    pub selected: bool,
58    pub status: QueueStatus,
59    pub progress: f64,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum FocusTarget {
64    MediaPool,
65    Queue,
66    ExportSettings,
67    Preview,
68    Grade,
69}
70
71#[derive(Debug, Clone, Copy)]
72pub struct GradeSliders {
73    pub exposure: f32,
74    pub contrast: f32,
75    pub saturation: f32,
76    pub shadows: f32,
77    pub highlights: f32,
78    pub temperature: f32,
79    pub tint: f32,
80    pub sharpen: f32,
81}
82
83impl GradeSliders {
84    pub fn name(index: usize) -> &'static str {
85        match index {
86            0 => "Exposure",
87            1 => "Contrast",
88            2 => "Saturation",
89            3 => "Shadows",
90            4 => "Highlights",
91            5 => "Temp",
92            6 => "Tint",
93            7 => "Sharpen",
94            _ => "",
95        }
96    }
97
98    pub fn default_val(index: usize) -> f32 {
99        match index {
100            0 => 0.0,
101            1 => 1.0,
102            2 => 1.0,
103            3 => 0.0,
104            4 => 0.0,
105            5 => 5200.0,
106            6 => 0.0,
107            7 => 0.0,
108            _ => 0.0,
109        }
110    }
111
112    pub fn min(index: usize) -> f32 {
113        match index {
114            0 => -5.0,
115            1 => 0.0,
116            2 => 0.0,
117            3 => -1.0,
118            4 => -1.0,
119            5 => 2000.0,
120            6 => -100.0,
121            7 => 0.0,
122            _ => 0.0,
123        }
124    }
125
126    pub fn max(index: usize) -> f32 {
127        match index {
128            0 => 5.0,
129            1 => 2.0,
130            2 => 2.0,
131            3 => 1.0,
132            4 => 1.0,
133            5 => 10000.0,
134            6 => 100.0,
135            7 => 1.0,
136            _ => 1.0,
137        }
138    }
139
140    pub fn step_small(index: usize) -> f32 {
141        match index {
142            0 => 0.1,
143            5 => 50.0,
144            6 => 1.0,
145            _ => 0.05,
146        }
147    }
148
149    pub fn step_large(index: usize) -> f32 {
150        match index {
151            0 => 1.0,
152            5 => 500.0,
153            6 => 10.0,
154            _ => 0.25,
155        }
156    }
157
158    pub fn value(&self, index: usize) -> f32 {
159        match index {
160            0 => self.exposure,
161            1 => self.contrast,
162            2 => self.saturation,
163            3 => self.shadows,
164            4 => self.highlights,
165            5 => self.temperature,
166            6 => self.tint,
167            7 => self.sharpen,
168            _ => 0.0,
169        }
170    }
171
172    pub fn normalized(&self, index: usize) -> f32 {
173        let v = self.value(index);
174        let lo = Self::min(index);
175        let hi = Self::max(index);
176        if hi <= lo { return 0.5; }
177        ((v - lo) / (hi - lo)).clamp(0.0, 1.0)
178    }
179
180    pub fn display_value(&self, index: usize) -> String {
181        let sign = |x: f32| if x >= 0.0 { "+" } else { "" };
182        match index {
183            0 => format!("{}{:.1} stops", sign(self.exposure), self.exposure),
184            1 => format!("{:.2}x", self.contrast),
185            2 => format!("{:.2}x", self.saturation),
186            3 => format!("{}{:.2}", sign(self.shadows), self.shadows),
187            4 => format!("{}{:.2}", sign(self.highlights), self.highlights),
188            5 => format!("{:.0}K", self.temperature),
189            6 => format!("{}{:.0}", sign(self.tint), self.tint),
190            _ => format!("{:.2}", self.sharpen),
191        }
192    }
193
194    pub fn set(&mut self, index: usize, v: f32) {
195        let lo = Self::min(index);
196        let hi = Self::max(index);
197        let v = v.clamp(lo, hi);
198        match index {
199            0 => self.exposure = v,
200            1 => self.contrast = v,
201            2 => self.saturation = v,
202            3 => self.shadows = v,
203            4 => self.highlights = v,
204            5 => self.temperature = v,
205            6 => self.tint = v,
206            7 => self.sharpen = v,
207            _ => {}
208        }
209    }
210
211    pub fn apply_delta(&mut self, index: usize, step: f32) {
212        let cur = self.value(index);
213        self.set(index, cur + step);
214    }
215
216    pub fn count() -> usize { 8 }
217}
218
219impl Default for GradeSliders {
220    fn default() -> Self {
221        Self {
222            exposure: 0.0,
223            contrast: 1.0,
224            saturation: 1.0,
225            shadows: 0.0,
226            highlights: 0.0,
227            temperature: 5200.0,
228            tint: 0.0,
229            sharpen: 0.0,
230        }
231    }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum ImportPopupState {
236    Hidden,
237    DroppedFiles {
238        files: Vec<String>,
239        folder: String,
240        all_in_folder: Vec<String>,
241    },
242}
243
244#[derive(Debug)]
245pub enum ExportEvent {
246    Progress(f64),
247    Stats(Arc<PipelineStats>),
248    Done(Result<()>),
249}
250
251/// Snapshot of the most recently finished export. Kept so the UI can show a
252/// post-render summary (codec, settings, elapsed time, output path, etc.)
253/// instead of immediately reverting to the preview panel.
254#[derive(Debug, Clone)]
255pub struct ExportSummary {
256    pub output_path: String,
257    pub codec_label: String,
258    pub profile_label: String,
259    pub color_space: String,
260    pub transfer: String,
261    pub rate_control: String,
262    pub frame_count: usize,
263    pub elapsed: Duration,
264    pub result: Result<(), String>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum Screen {
269    Browse,
270    Info,
271    Export,
272}
273
274/// Tracks real render-loop frame rate using a simple per-second counter.
275///
276/// Updates once per second with EMA smoothing (`0.9 * prev + 0.1 * current`)
277/// to dampen visual jitter. The value is exposed via `fps()` and rendered
278/// right-aligned in the header bar.
279#[derive(Debug, Clone)]
280pub struct FPSCounter {
281    last_draw: Instant,
282    frames_this_second: u32,
283    second_start: Instant,
284    smooth_fps: f64,
285}
286
287impl FPSCounter {
288    pub fn new() -> Self {
289        Self {
290            last_draw: Instant::now(),
291            frames_this_second: 0,
292            second_start: Instant::now(),
293            smooth_fps: 0.0,
294        }
295    }
296
297    /// Call once per frame before measuring elapsed time.
298    pub fn tick(&mut self) {
299        let now = Instant::now();
300        self.frames_this_second += 1;
301        if now.duration_since(self.second_start).as_secs_f64() >= 1.0 {
302            let fps = self.frames_this_second as f64;
303            if self.smooth_fps == 0.0 {
304                self.smooth_fps = fps;
305            } else {
306                self.smooth_fps = self.smooth_fps * 0.9 + fps * 0.1;
307            }
308            self.frames_this_second = 0;
309            self.second_start = now;
310        }
311        self.last_draw = now;
312    }
313
314    pub fn fps(&self) -> f64 {
315        self.smooth_fps
316    }
317}
318
319pub struct App {
320    pub running: bool,
321    pub screen: Screen,
322    pub file_path: Option<String>,
323    pub file_info: Option<McrawFileInfo>,
324    pub frame_index: usize,
325    pub frame_count: usize,
326    pub encode_jobs: Vec<EncodeJob>,
327    pub status_message: String,
328    pub show_help: bool,
329    pub error: Option<String>,
330    pub browser: FileBrowser,
331
332    pub is_exporting: bool,
333    pub export_cancelled: bool,
334    pub export_progress: f64,
335    pub export_rx: Option<mpsc::Receiver<ExportEvent>>,
336    pub cancel_token: Option<Arc<AtomicBool>>,
337
338    /// Snapshot of the most-recent finished export — drives the post-render
339    /// summary panel. Cleared when the user starts a new export.
340    pub last_export_summary: Option<ExportSummary>,
341
342    /// Settings captured at `start_export` time so `poll_export` can build
343    /// an accurate `ExportSummary` even if the user has since cycled the
344    /// export-settings panel to different values.
345    pub pending_export_summary: Option<ExportSummary>,
346
347    // Which queue item is currently being rendered (for sequential batch)
348    pub current_rendering_index: Option<usize>,
349
350    // Export folder for the current session
351    pub export_folder: Option<std::path::PathBuf>,
352
353    // Favourite folders for quick browser navigation
354    pub favourite_folders: Vec<std::path::PathBuf>,
355
356    // Help overlay scroll position
357    pub help_scroll: u16,
358
359    // Culling mode flag
360    pub show_culling: bool,
361
362    // Full-screen grade mode (Shift+G)
363    pub show_grade_screen: bool,
364
365    // Persistent export settings
366    pub export_color_space: ColorSpace,
367    pub export_transfer_function: TransferFunction,
368    pub export_codec_family: CodecFamily,
369    pub export_focus: ExportFocus,
370    pub export_start_time: Option<Instant>,
371
372    // Sticky per-codec profiles
373    pub prores_profile: ProResProfile,
374    pub dnxhr_profile: DnxhrProfile,
375    pub hevc_profile: HevcProfile,
376    pub h264_profile: H264Profile,
377    pub av1_profile: Av1Profile,
378    pub vp9_profile: Vp9Profile,
379
380    // Runtime hardware probe result
381    pub hardware_caps: crate::hardware::HardwareCaps,
382
383    // Rate control
384    pub active_rate_control: RateControl,
385    pub is_editing_custom_rate: bool,
386
387    // Grading sliders (Phase 2)
388    pub grade_sliders: GradeSliders,
389    pub grade_focus: usize,
390    /// Active mouse drag on a grade slider: (slider_index, track_x, track_width)
391    pub grade_dragging: Option<(usize, u16, u16)>,
392
393    // Media pool / queue workflow
394    pub imported_files: Vec<ImportedFile>,
395    pub media_pool_index: usize,
396
397    pub queue: Vec<QueuedFile>,
398    pub queue_index: usize,
399
400    pub show_browser: bool,
401    pub import_popup: ImportPopupState,
402
403    pub focus_target: FocusTarget,
404
405    pub show_full_info: bool,
406
407    // Browser double-click detection
408    pub last_browser_click: Option<(Instant, usize)>,
409
410    // Grade slider double-click detection
411    pub last_grade_click: Option<(Instant, usize)>,
412
413    // Drag-drop visual feedback
414    pub drop_highlight: Option<Instant>,
415
416    // Async drag-drop import state
417    pub drop_import_rx: Option<mpsc::Receiver<DropImportEvent>>,
418    pub drop_import_cancel: Option<Arc<AtomicBool>>,
419
420    // Drop preview overlay for visual feedback
421    pub drop_preview: Option<DropPreview>,
422
423    // Persistent ListState offset for browser (prevents viewport jumping on click)
424    pub browser_scroll_offset: Cell<usize>,
425
426    // Pinned favourites bar toggle
427    pub show_favourites_bar: bool,
428
429    // When true, the browser list is replaced by a flat view of the
430    // user's favourite folders (f-key toggle). `..` is hidden in this
431    // view because the favourites list isn't a filesystem hierarchy.
432    pub browsing_favourites: bool,
433
434    // Persistent ListState offset for the favourites list view
435    pub favourites_scroll_offset: Cell<usize>,
436
437    // Timestamp + index of last clicked favourite (for d-key removal)
438    pub last_clicked_favourite: Option<(Instant, usize)>,
439
440    // -------------------------------------------------------------------
441    // Export presets
442    // -------------------------------------------------------------------
443    /// User-saved export setting bundles. Loaded from
444    /// `presets.json` at startup, written back on every change.
445    pub presets: Vec<crate::preset::ExportPreset>,
446
447    /// Name of the preset that was last applied, if any. Shown in the
448    /// Export Settings panel header so the user can see *why* the current
449    /// settings look the way they do.
450    pub active_preset: Option<String>,
451
452    /// State of the preset-picker overlay.
453    pub preset_picker: PresetPickerState,
454
455    /// True while the user is typing a name for a new preset. Captures
456    /// the live text and the cursor position. Esc cancels, Enter saves.
457    pub preset_naming: Option<PresetNamingState>,
458
459    // Animation state
460    pub spinner_frame: u8,
461    pub progress_anim_offset: u8,
462
463    // Real-time render-loop performance meter
464    pub fps_counter: FPSCounter,
465
466    // Heatwave shockwave countdown (0 = inactive)
467    pub shockwave_ticks_remaining: u8,
468
469    // Focus strip state — whether the single-line HUD is in expanded slider view
470    pub grade_strip_active: bool,
471    // Parameter morph animation: (old_index, ticks_remaining)
472    pub grade_morph: Option<(usize, u8)>,
473    // Phosphor trail: (track_position 0..1, ticks_remaining)
474    pub phosphor_trail: Vec<(f32, u8)>,
475    // Snapshot for before/after comparison (B key)
476    pub grade_before_snapshot: Option<GradeSliders>,
477    // Focus strip idle counter: decrements each tick
478    pub grade_strip_idle_ticks: u8,
479}
480
481/// Overlay state for the preset-picker. `Shown` holds the list, cursor
482/// index, and a transient error/info string rendered at the bottom.
483#[derive(Debug, Clone, Default)]
484pub struct PresetPickerState {
485    pub open: bool,
486    pub index: usize,
487    pub message: Option<String>,
488}
489
490#[derive(Debug, Clone)]
491pub struct PresetNamingState {
492    pub name: String,
493    pub message: Option<String>,
494}
495
496/// Event from async drag-drop import worker
497pub enum DropImportEvent {
498    FileReady { path: String, info: McrawFileInfo },
499    Failed { path: String, error: String },
500    Complete { imported: usize, failed: usize },
501}
502
503/// Visual preview of dropped files
504pub struct DropPreview {
505    pub files: Vec<String>,
506    pub start_time: Instant,
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum ExportFocus {
511    ColorSpace,
512    TransferFunction,
513    CodecFamily,
514    Profile,
515    RateControl,
516}
517
518impl App {
519    fn favourites_file() -> Option<PathBuf> {
520        let mut dir = dirs::config_dir()?;
521        dir.push("mcraw-tui");
522        std::fs::create_dir_all(&dir).ok()?;
523        dir.push("favourites.json");
524        Some(dir)
525    }
526
527    fn load_favourites() -> Vec<PathBuf> {
528        let path = match Self::favourites_file() {
529            Some(p) => p,
530            None => return Vec::new(),
531        };
532        let data = match std::fs::read_to_string(&path) {
533            Ok(d) => d,
534            Err(_) => return Vec::new(),
535        };
536        serde_json::from_str(&data).unwrap_or_default()
537    }
538
539    fn save_favourites(&self) {
540        let path = match Self::favourites_file() {
541            Some(p) => p,
542            None => return,
543        };
544        if let Ok(data) = serde_json::to_string(&self.favourite_folders) {
545            let _ = std::fs::write(path, data);
546        }
547    }
548
549    pub fn new() -> Self {
550        let caps = probe_hardware();
551        App {
552            running: true,
553            screen: Screen::Browse,
554            file_path: None,
555            file_info: None,
556            frame_index: 0,
557            frame_count: 0,
558            encode_jobs: Vec::new(),
559            status_message: String::from("Ready | Drag-drop .mcraw files or press b to browse"),
560            show_help: false,
561            error: None,
562            browser: FileBrowser::new(),
563
564            is_exporting: false,
565            export_cancelled: false,
566            export_progress: 0.0,
567            export_rx: None,
568            cancel_token: None,
569            last_export_summary: None,
570            pending_export_summary: None,
571
572            export_color_space: ColorSpace::Rec709,
573            export_transfer_function: TransferFunction::Gamma24,
574            export_codec_family: CodecFamily::HEVC,
575            export_focus: ExportFocus::CodecFamily,
576            export_start_time: None,
577
578            prores_profile: ProResProfile::HQ,
579            dnxhr_profile: DnxhrProfile::HQX,
580            hevc_profile: HevcProfile::Main10_420,
581            h264_profile: H264Profile::Main8bit,
582            av1_profile: Av1Profile::Profile0_420_10bit,
583            vp9_profile: Vp9Profile::Profile2_420_10bit,
584
585            hardware_caps: caps,
586            active_rate_control: RateControl::Lossless,
587            is_editing_custom_rate: false,
588
589            imported_files: Vec::new(),
590            media_pool_index: 0,
591            queue: Vec::new(),
592            queue_index: 0,
593            show_browser: true,
594            current_rendering_index: None,
595            export_folder: None,
596            favourite_folders: Self::load_favourites(),
597            help_scroll: 0,
598            show_culling: false,
599            show_grade_screen: false,
600            import_popup: ImportPopupState::Hidden,
601            focus_target: FocusTarget::MediaPool,
602            show_full_info: false,
603            last_browser_click: None,
604            last_grade_click: None,
605            drop_highlight: None,
606            drop_import_rx: None,
607            drop_import_cancel: None,
608            drop_preview: None,
609            browser_scroll_offset: Cell::new(0),
610            show_favourites_bar: true,
611            last_clicked_favourite: None,
612            browsing_favourites: false,
613            favourites_scroll_offset: Cell::new(0),
614            presets: ExportPreset::load_all(),
615            active_preset: None,
616            preset_picker: PresetPickerState::default(),
617            preset_naming: None,
618
619            spinner_frame: 0,
620            progress_anim_offset: 0,
621            fps_counter: FPSCounter::new(),
622            shockwave_ticks_remaining: 0,
623            grade_sliders: GradeSliders::default(),
624            grade_focus: 0,
625            grade_dragging: None,
626            grade_strip_active: true,
627            grade_morph: None,
628            phosphor_trail: Vec::new(),
629            grade_before_snapshot: None,
630            grade_strip_idle_ticks: 0,
631        }
632    }
633
634    // -----------------------------------------------------------------------
635    // File loading
636    // -----------------------------------------------------------------------
637
638    pub fn load_file(&mut self, path: String) {
639        tracing::info!("load_file: path={}", path);
640        self.error = None;
641        self.status_message = String::new();
642        match McrawFileInfo::from_path(&path) {
643            Ok(mut info) => {
644                tracing::debug!("file parsed: frames={} {}x{} fps={}", info.frame_count, info.width, info.height, info.fps);
645                if let Ok(decoder) = Decoder::new(&path) {
646                    if let Ok(container_meta) = decoder.container_metadata() {
647                        let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
648                            let mut r = [0.0; 9];
649                            for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
650                            r
651                        };
652                        let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
653
654                        info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
655                        if non_zero(&container_meta.color_matrix2) {
656                            info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
657                        }
658                        if non_zero(&container_meta.forward_matrix1) {
659                            info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
660                        }
661                        if non_zero(&container_meta.forward_matrix2) {
662                            info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
663                        }
664                        if container_meta.has_calibration_illuminants {
665                            info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
666                            info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
667                        }
668
669                        if container_meta.white_level > 0.0 {
670                            info.white_level = container_meta.white_level;
671                        }
672                        if container_meta.black_level_count > 0 {
673                            info.black_level = container_meta.black_level[0];
674                        }
675                    }
676                    if let Ok(timestamps) = decoder.timestamps() {
677                        info.frame_count = timestamps.len() as u32;
678                        if timestamps.len() >= 2 {
679                            let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
680                            if duration_ns > 0 {
681                                let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
682                                info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
683                            }
684                        }
685                        if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
686                            info.width = first_frame_meta.width as u16;
687                            info.height = first_frame_meta.height as u16;
688                        }
689                        // Initialize grade temperature from file white balance
690                        if let Some(wb) = info.camera_metadata.wb_multipliers {
691                            let r_gain = wb[0];
692                            let b_gain = wb[2];
693                            let ratio = (r_gain / b_gain.max(1e-6)).clamp(0.1, 10.0);
694                            let temp = if ratio >= 1.0 {
695                                5200.0 + (ratio - 1.0) * 3000.0
696                            } else {
697                                5200.0 - (1.0 - ratio) * 3000.0
698                            };
699                            self.grade_sliders.set(5, temp.clamp(2000.0, 10000.0));
700                        } else {
701                            self.grade_sliders.set(5, 5200.0);
702                        }
703                    }
704                }
705
706                self.file_info = Some(info.clone());
707                self.frame_count = info.frame_count as usize;
708                self.file_path = Some(path.clone());
709
710                let already = self.imported_files.iter().any(|f| f.path == path);
711                if !already {
712                    self.imported_files.push(ImportedFile {
713                        path: path.clone(),
714                        info: info.clone(),
715                        selected: true,
716                    });
717                    self.media_pool_index = self.imported_files.len() - 1;
718                    tracing::info!("file added to media pool: index={}", self.media_pool_index);
719                } else {
720                    tracing::debug!("file already in media pool, skipping");
721                }
722
723                self.status_message = format!("Imported: {}", path);
724                tracing::info!("file loaded successfully: {}", path);
725            }
726            Err(e) => {
727                tracing::error!("failed to load file {}: {}", path, e);
728                self.error = Some(format!("Failed to load file: {}", e));
729                self.status_message = format!("Error: {}", e);
730            }
731        }
732    }
733
734    /// Add multiple files to the media pool (used by drag-drop).
735    /// Returns (imported_count, failed_count).
736    pub fn load_files_batch(&mut self, paths: &[String]) -> (usize, usize) {
737        tracing::info!("load_files_batch: count={}", paths.len());
738        let mut imported = 0;
739        let mut failed = 0;
740        for path in paths {
741            self.error = None;
742            match McrawFileInfo::from_path(path) {
743                Ok(mut info) => {
744                    if let Ok(decoder) = Decoder::new(path) {
745                        if let Ok(container_meta) = decoder.container_metadata() {
746                            let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
747                                let mut r = [0.0; 9];
748                                for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
749                                r
750                            };
751                            let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
752                            info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
753                            if non_zero(&container_meta.color_matrix2) {
754                                info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
755                            }
756                            if non_zero(&container_meta.forward_matrix1) {
757                                info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
758                            }
759                            if non_zero(&container_meta.forward_matrix2) {
760                                info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
761                            }
762                            if container_meta.has_calibration_illuminants {
763                                info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
764                                info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
765                            }
766                            if container_meta.white_level > 0.0 {
767                                info.white_level = container_meta.white_level;
768                            }
769                            if container_meta.black_level_count > 0 {
770                                info.black_level = container_meta.black_level[0];
771                            }
772                        }
773                        if let Ok(timestamps) = decoder.timestamps() {
774                            info.frame_count = timestamps.len() as u32;
775                            if timestamps.len() >= 2 {
776                                let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
777                                if duration_ns > 0 {
778                                    let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
779                                    info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
780                                }
781                            }
782                            if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
783                                info.width = first_frame_meta.width as u16;
784                                info.height = first_frame_meta.height as u16;
785                            }
786                        }
787                    }
788
789                    let already = self.imported_files.iter().any(|f| f.path == *path);
790                    if !already {
791                        self.imported_files.push(ImportedFile {
792                            path: path.clone(),
793                            info: info.clone(),
794                            selected: true,
795                        });
796                        imported += 1;
797                        tracing::debug!("batch imported: {} ({} total)", path, self.imported_files.len());
798                    }
799                }
800                Err(e) => {
801                    failed += 1;
802                    tracing::warn!("batch import failed for {}: {}", path, e);
803                }
804            }
805        }
806        // Select the first newly imported file
807        if imported > 0 && self.imported_files.len() > 0 {
808            self.media_pool_index = self.imported_files.len() - imported;
809            self.file_info = Some(self.imported_files[self.media_pool_index].info.clone());
810            self.file_path = Some(self.imported_files[self.media_pool_index].path.clone());
811            self.frame_count = self.imported_files[self.media_pool_index].info.frame_count as usize;
812        }
813        (imported, failed)
814    }
815
816    /// Start async import of dropped files on a background thread.
817    /// Returns immediately; results arrive via DropImportEvent channel.
818    pub fn start_async_import(&mut self, paths: Vec<String>) {
819        // Cancel any in-progress import
820        if let Some(cancel) = self.drop_import_cancel.take() {
821            cancel.store(true, Ordering::Relaxed);
822        }
823
824        let (tx, rx) = mpsc::channel::<DropImportEvent>();
825        let cancel_flag = Arc::new(AtomicBool::new(false));
826        self.drop_import_cancel = Some(cancel_flag.clone());
827        self.drop_import_rx = Some(rx);
828
829        // Show preview overlay
830        self.drop_preview = Some(DropPreview {
831            files: paths.iter()
832                .filter(|p| p.to_lowercase().ends_with(".mcraw"))
833                .map(|p| p.clone())
834                .collect(),
835            start_time: Instant::now(),
836        });
837
838        let total = paths.len();
839        self.status_message = format!("Importing {} file(s)...", total);
840
841        std::thread::spawn(move || {
842            let mut imported = 0;
843            let mut failed = 0;
844
845            for path in paths {
846                if cancel_flag.load(Ordering::Relaxed) {
847                    tracing::info!("async drag-drop import cancelled");
848                    break;
849                }
850
851                let path_clone = path.clone();
852                match McrawFileInfo::from_path(&path) {
853                    Ok(mut info) => {
854                        // Enhance with decoder metadata (same as load_file)
855                        if let Ok(decoder) = Decoder::new(&path) {
856                            if let Ok(container_meta) = decoder.container_metadata() {
857                                let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
858                                    let mut r = [0.0; 9];
859                                    for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
860                                    r
861                                };
862                                let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
863                                info.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
864                                if non_zero(&container_meta.color_matrix2) {
865                                    info.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
866                                }
867                                if non_zero(&container_meta.forward_matrix1) {
868                                    info.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
869                                }
870                                if non_zero(&container_meta.forward_matrix2) {
871                                    info.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
872                                }
873                                if container_meta.has_calibration_illuminants {
874                                    info.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
875                                    info.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
876                                }
877                                if container_meta.white_level > 0.0 {
878                                    info.white_level = container_meta.white_level;
879                                }
880                                if container_meta.black_level_count > 0 {
881                                    info.black_level = container_meta.black_level[0];
882                                }
883                            }
884                            if let Ok(timestamps) = decoder.timestamps() {
885                                info.frame_count = timestamps.len() as u32;
886                                if timestamps.len() >= 2 {
887                                    let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
888                                    if duration_ns > 0 {
889                                        let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
890                                        info.fps = (info.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
891                                    }
892                                }
893                                if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
894                                    info.width = first_frame_meta.width as u16;
895                                    info.height = first_frame_meta.height as u16;
896                                }
897                            }
898                        }
899
900                        let _ = tx.send(DropImportEvent::FileReady { path: path_clone, info });
901                        imported += 1;
902                    }
903                    Err(e) => {
904                        let _ = tx.send(DropImportEvent::Failed {
905                            path: path_clone,
906                            error: e.to_string(),
907                        });
908                        failed += 1;
909                        tracing::warn!("async drag-drop import failed: {}: {}", path, e);
910                    }
911                }
912            }
913
914            let _ = tx.send(DropImportEvent::Complete { imported, failed });
915        });
916    }
917
918    /// Poll for async drag-drop import results. Call every frame.
919    pub fn poll_drop_import(&mut self) {
920        let rx = match self.drop_import_rx.take() {
921            Some(rx) => rx,
922            None => return,
923        };
924
925        let mut keep_rx = true;
926        while let Ok(event) = rx.try_recv() {
927            match event {
928                DropImportEvent::FileReady { path, info } => {
929                    let already = self.imported_files.iter().any(|f| f.path == path);
930                    if !already {
931                        self.imported_files.push(ImportedFile {
932                            path: path.clone(),
933                            info: info.clone(),
934                            selected: true,
935                        });
936                        // Select the first imported file
937                        if self.imported_files.len() == 1 {
938                            self.media_pool_index = 0;
939                            self.file_info = Some(info.clone());
940                            self.file_path = Some(path.clone());
941                            self.frame_count = info.frame_count as usize;
942                        }
943                        tracing::debug!("async imported: {} ({} total)", path, self.imported_files.len());
944                    }
945                }
946                DropImportEvent::Failed { path, error } => {
947                    tracing::warn!("async import failed: {}: {}", path, error);
948                }
949                DropImportEvent::Complete { imported, failed } => {
950                    keep_rx = false;
951                    self.drop_import_cancel = None;
952                    if imported > 0 {
953                        self.media_pool_index = self.imported_files.len().saturating_sub(imported);
954                        if let Some(f) = self.imported_files.get(self.media_pool_index) {
955                            self.file_info = Some(f.info.clone());
956                            self.file_path = Some(f.path.clone());
957                            self.frame_count = f.info.frame_count as usize;
958                        }
959                    }
960                    if failed > 0 {
961                        self.status_message = format!("Imported {} file(s), {} failed", imported, failed);
962                    } else {
963                        self.status_message = format!("Imported {} file(s)", imported);
964                    }
965                    tracing::info!("async drag-drop import complete: {} imported, {} failed", imported, failed);
966                }
967            }
968        }
969
970        if keep_rx {
971            self.drop_import_rx = Some(rx);
972        }
973    }
974
975    pub fn load_all_in_folder(&mut self, dir: &std::path::Path) {
976        if let Ok(entries) = std::fs::read_dir(dir) {
977            let mut mcraw_paths: Vec<String> = entries
978                .filter_map(|e| e.ok())
979                .map(|e| e.path())
980                .filter(|p| p.extension().map_or(false, |ext| ext == "mcraw"))
981                .map(|p| p.to_string_lossy().to_string())
982                .collect();
983            mcraw_paths.sort();
984            let count = mcraw_paths.len();
985            for path in mcraw_paths {
986                self.load_file(path);
987            }
988            if count > 0 {
989                self.status_message = format!("Imported {} .mcraw files from {}", count, dir.display());
990            } else {
991                self.status_message = format!("No .mcraw files found in {}", dir.display());
992            }
993        }
994    }
995
996    // -----------------------------------------------------------------------
997    // Media pool helpers
998    // -----------------------------------------------------------------------
999
1000    pub fn focused_file_info(&self) -> Option<&McrawFileInfo> {
1001        self.imported_files.get(self.media_pool_index).map(|f| &f.info)
1002    }
1003
1004    pub fn toggle_media_pool_selection(&mut self) {
1005        if let Some(f) = self.imported_files.get_mut(self.media_pool_index) {
1006            f.selected = !f.selected;
1007        }
1008    }
1009
1010    pub fn add_selected_to_queue(&mut self) {
1011        let selected: Vec<ImportedFile> = self.imported_files.iter()
1012            .filter(|f| f.selected)
1013            .cloned()
1014            .collect();
1015        if selected.is_empty() {
1016            self.status_message = "No files selected - use Space to select, then a to add".to_string();
1017            return;
1018        }
1019        let count = selected.len();
1020        for imp in &selected {
1021            let already = self.queue.iter().any(|q| q.path == imp.path);
1022            if !already {
1023                self.queue.push(QueuedFile {
1024                    path: imp.path.clone(),
1025                    info: imp.info.clone(),
1026                    selected: true,
1027                    status: QueueStatus::Waiting,
1028                    progress: 0.0,
1029                });
1030            }
1031        }
1032        self.status_message = format!("Added {} file(s) to render queue", count);
1033    }
1034
1035    pub fn add_all_to_queue(&mut self) {
1036        if self.imported_files.is_empty() {
1037            self.status_message = "No files in media pool".to_string();
1038            return;
1039        }
1040        let count = self.imported_files.len();
1041        for imp in &self.imported_files {
1042            let already = self.queue.iter().any(|q| q.path == imp.path);
1043            if !already {
1044                self.queue.push(QueuedFile {
1045                    path: imp.path.clone(),
1046                    info: imp.info.clone(),
1047                    selected: true,
1048                    status: QueueStatus::Waiting,
1049                    progress: 0.0,
1050                });
1051            }
1052        }
1053        self.status_message = format!("Added all {} file(s) to render queue", count);
1054    }
1055
1056    pub fn remove_from_media_pool(&mut self) {
1057        if self.imported_files.is_empty() {
1058            return;
1059        }
1060        let name = self.imported_files[self.media_pool_index]
1061            .path
1062            .split(std::path::MAIN_SEPARATOR)
1063            .last()
1064            .unwrap_or("unknown")
1065            .to_string();
1066        self.imported_files.remove(self.media_pool_index);
1067        if self.media_pool_index >= self.imported_files.len() && self.imported_files.len() > 0 {
1068            self.media_pool_index = self.imported_files.len() - 1;
1069        }
1070        self.status_message = format!("Removed {} from media pool", name);
1071    }
1072
1073    // -----------------------------------------------------------------------
1074    // Queue helpers
1075    // -----------------------------------------------------------------------
1076
1077    pub fn toggle_queue_selection(&mut self) {
1078        if let Some(q) = self.queue.get_mut(self.queue_index) {
1079            q.selected = !q.selected;
1080        }
1081    }
1082
1083    pub fn remove_from_queue(&mut self) {
1084        if self.queue.is_empty() {
1085            return;
1086        }
1087        let has_selected = self.queue.iter().any(|q| q.selected);
1088        if has_selected {
1089            self.queue.retain(|q| !q.selected);
1090            self.status_message = "Removed selected items from queue".to_string();
1091        } else {
1092            let name = self.queue[self.queue_index]
1093                .path
1094                .split(std::path::MAIN_SEPARATOR)
1095                .last()
1096                .unwrap_or("unknown")
1097                .to_string();
1098            self.queue.remove(self.queue_index);
1099            if self.queue_index >= self.queue.len() && self.queue.len() > 0 {
1100                self.queue_index = self.queue.len() - 1;
1101            }
1102            self.status_message = format!("Removed {} from queue", name);
1103        }
1104        if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1105            self.queue_index = self.queue.len() - 1;
1106        }
1107    }
1108
1109    pub fn clear_completed_queue(&mut self) {
1110        let before = self.queue.len();
1111        self.queue.retain(|q| !matches!(q.status, QueueStatus::Completed | QueueStatus::Failed(_)));
1112        let removed = before - self.queue.len();
1113        if removed > 0 {
1114            self.status_message = format!("Cleared {} completed/failed item(s)", removed);
1115        } else {
1116            self.status_message = "No completed/failed items to clear".to_string();
1117        }
1118        if self.queue_index >= self.queue.len() && !self.queue.is_empty() {
1119            self.queue_index = self.queue.len() - 1;
1120        }
1121    }
1122
1123    pub fn render_selected(&mut self) {
1124        let selected_indices: Vec<usize> = self.queue.iter()
1125            .enumerate()
1126            .filter(|(_, q)| q.selected)
1127            .map(|(i, _)| i)
1128            .collect();
1129        if selected_indices.is_empty() {
1130            self.status_message = "No items selected in queue - use Space to select".to_string();
1131            return;
1132        }
1133        self.status_message = format!("Starting render of {} selected file(s)...", selected_indices.len());
1134        // Start the first one
1135        if let Some(&first_idx) = selected_indices.first() {
1136            self.current_rendering_index = Some(first_idx);
1137            let q = &self.queue[first_idx];
1138            self.file_info = Some(q.info.clone());
1139            self.file_path = Some(q.path.clone());
1140            self.frame_count = q.info.frame_count as usize;
1141            self.start_export();
1142        }
1143    }
1144
1145    pub fn render_all(&mut self) {
1146        if self.queue.is_empty() {
1147            self.status_message = "Queue is empty".to_string();
1148            return;
1149        }
1150        self.status_message = format!("Starting render of all {} file(s)...", self.queue.len());
1151        for q in &mut self.queue {
1152            q.selected = true;
1153        }
1154        // Start from the first item
1155        self.current_rendering_index = Some(0);
1156        if let Some(q) = self.queue.first() {
1157            self.file_info = Some(q.info.clone());
1158            self.file_path = Some(q.path.clone());
1159            self.frame_count = q.info.frame_count as usize;
1160            self.start_export();
1161        }
1162    }
1163
1164    fn start_next_queued_render(&mut self) {
1165        // Find the next selected queue item that's Waiting
1166        if let Some(current) = self.current_rendering_index {
1167            let next_idx = (current + 1..self.queue.len())
1168                .find(|&i| self.queue[i].selected && self.queue[i].status == QueueStatus::Waiting);
1169            if let Some(idx) = next_idx {
1170                self.current_rendering_index = Some(idx);
1171                self.queue[idx].status = QueueStatus::Rendering;
1172                let q = &self.queue[idx];
1173                self.file_info = Some(q.info.clone());
1174                self.file_path = Some(q.path.clone());
1175                self.frame_count = q.info.frame_count as usize;
1176                self.start_export();
1177            } else {
1178                // No more items to render
1179                self.current_rendering_index = None;
1180                let done = self.queue.iter().filter(|q| q.selected && q.status == QueueStatus::Completed).count();
1181                let total = self.queue.iter().filter(|q| q.selected).count();
1182                self.status_message = format!("Batch render complete: {}/{} done", done, total);
1183            }
1184        }
1185    }
1186
1187    // -----------------------------------------------------------------------
1188    // Export profile helpers
1189    // -----------------------------------------------------------------------
1190
1191    pub fn active_profile_is_8bit(&self) -> bool {
1192        match self.export_codec_family {
1193            CodecFamily::ProRes => false,
1194            CodecFamily::DNxHR => false,
1195            CodecFamily::HEVC => self.hevc_profile.is_8bit(),
1196            CodecFamily::H264 => self.h264_profile.is_8bit(),
1197            CodecFamily::AV1 => self.av1_profile.is_8bit(),
1198            CodecFamily::VP9 => self.vp9_profile.is_8bit(),
1199        }
1200    }
1201
1202    pub fn active_profile_name(&self) -> &'static str {
1203        match self.export_codec_family {
1204            CodecFamily::ProRes => self.prores_profile.name(),
1205            CodecFamily::DNxHR => self.dnxhr_profile.name(),
1206            CodecFamily::HEVC => self.hevc_profile.name(),
1207            CodecFamily::H264 => self.h264_profile.name(),
1208            CodecFamily::AV1 => self.av1_profile.name(),
1209            CodecFamily::VP9 => self.vp9_profile.name(),
1210        }
1211    }
1212
1213    pub fn cycle_rate_control(&mut self) {
1214        self.active_rate_control = self.active_rate_control.next();
1215        self.is_editing_custom_rate = false;
1216        self.status_message = format!("Rate: {}", self.active_rate_control.name());
1217    }
1218
1219    pub fn cycle_codec(&mut self, forward: bool) {
1220        self.export_codec_family = if forward {
1221            self.export_codec_family.next()
1222        } else {
1223            self.export_codec_family.prev()
1224        };
1225        self.export_focus = ExportFocus::CodecFamily;
1226        self.status_message = format!("Codec: {}", self.export_codec_family.name());
1227    }
1228
1229    pub fn cycle_profile(&mut self, forward: bool) {
1230        match self.export_codec_family {
1231            CodecFamily::ProRes => {
1232                self.prores_profile = if forward { self.prores_profile.next() } else { self.prores_profile.prev() };
1233                self.status_message = format!("Profile: {}", self.prores_profile.name());
1234            }
1235            CodecFamily::DNxHR => {
1236                self.dnxhr_profile = if forward { self.dnxhr_profile.next() } else { self.dnxhr_profile.prev() };
1237                self.status_message = format!("Profile: {}", self.dnxhr_profile.name());
1238            }
1239            CodecFamily::HEVC => {
1240                self.hevc_profile = if forward { self.hevc_profile.next() } else { self.hevc_profile.prev() };
1241                self.status_message = format!("Profile: {}", self.hevc_profile.name());
1242            }
1243            CodecFamily::H264 => {
1244                self.h264_profile = if forward { self.h264_profile.next() } else { self.h264_profile.prev() };
1245                self.status_message = format!("Profile: {}", self.h264_profile.name());
1246            }
1247            CodecFamily::AV1 => {
1248                self.av1_profile = if forward { self.av1_profile.next() } else { self.av1_profile.prev() };
1249                self.status_message = format!("Profile: {}", self.av1_profile.name());
1250            }
1251            CodecFamily::VP9 => {
1252                self.vp9_profile = if forward { self.vp9_profile.next() } else { self.vp9_profile.prev() };
1253                self.status_message = format!("Profile: {}", self.vp9_profile.name());
1254            }
1255        }
1256        self.export_focus = ExportFocus::Profile;
1257    }
1258
1259    pub fn start_export(&mut self) {
1260        if self.is_exporting {
1261            tracing::info!("export cancelled by user (was already exporting)");
1262            self.cancel_export();
1263            self.status_message = "Export cancelled. Press V again to restart.".to_string();
1264            return;
1265        }
1266        let info = match self.file_info.clone() {
1267            Some(i) => i,
1268            None => {
1269                tracing::warn!("start_export called with no file loaded");
1270                self.status_message = "No file loaded".to_string();
1271                return;
1272            }
1273        };
1274
1275        if self.export_transfer_function.requires_10bit() && self.active_profile_is_8bit() {
1276            tracing::warn!("export blocked: log/HDR to 8-bit codec not supported");
1277            self.status_message = "Cannot export Log/HDR to 8-bit codec".to_string();
1278            return;
1279        }
1280
1281        let input_path = std::path::Path::new(&info.path);
1282        let parent = self.export_folder.clone().unwrap_or_else(|| {
1283            input_path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf()
1284        });
1285        let stem = input_path.file_stem().and_then(|s| s.to_str()).unwrap_or("output");
1286
1287        let ext = match self.export_codec_family {
1288            CodecFamily::ProRes | CodecFamily::DNxHR => "mov",
1289            CodecFamily::VP9 => "webm",
1290            _ => "mp4",
1291        };
1292        let tf_label = self.export_transfer_function.name().replace([' ', '(', ')', '.'], "");
1293        let cs_label = self.export_color_space.name().replace([' ', '(', ')', '.'], "");
1294        let filename = format!("{}_{}_{}.{}", stem, tf_label, cs_label, ext);
1295        let mut file = parent.join(&filename);
1296        let mut suffix = 1;
1297        while file.exists() {
1298            let base = format!("{}_{}_{}_{}", stem, tf_label, cs_label, suffix);
1299            file = parent.join(&base).with_extension(ext);
1300            suffix += 1;
1301        }
1302        let output_path = file.to_string_lossy().to_string();
1303        tracing::info!("export starting: output={} codec={} profile={} rate={}",
1304            output_path, self.export_codec_family.name(),
1305            self.active_profile_name(), self.active_rate_control.name());
1306        let cs = self.export_color_space;
1307        let tf = self.export_transfer_function;
1308        let cf = self.export_codec_family;
1309        let pp = self.prores_profile;
1310        let dp = self.dnxhr_profile;
1311        let hp = self.hevc_profile;
1312        let h4p = self.h264_profile;
1313        let ap = self.av1_profile;
1314        let vp = self.vp9_profile;
1315        let hevc_enc = self.hardware_caps.best_hevc_encoder.clone();
1316        let h264_enc = self.hardware_caps.best_h264_encoder.clone();
1317        let av1_enc = self.hardware_caps.best_av1_encoder.clone();
1318        let prores_enc = self.hardware_caps.best_prores_encoder.clone();
1319
1320        self.is_exporting = true;
1321        self.export_cancelled = false;
1322        self.export_progress = 0.0;
1323        self.export_start_time = Some(Instant::now());
1324        // Starting a fresh export — drop any previous summary so the UI
1325        // switches from the post-render panel back to the live progress
1326        // panel.
1327        self.last_export_summary = None;
1328        // Capture the settings that this export was launched with so the
1329        // summary stays accurate even if the user cycles the export-settings
1330        // panel mid-render.
1331        self.pending_export_summary = Some(ExportSummary {
1332            output_path: output_path.clone(),
1333            codec_label: cf.name().to_string(),
1334            profile_label: self.active_profile_name().to_string(),
1335            color_space: cs.name().to_string(),
1336            transfer: tf.name().to_string(),
1337            rate_control: self.active_rate_control.name(),
1338            frame_count: info.frame_count as usize,
1339            elapsed: Duration::default(),
1340            result: Ok(()),
1341        });
1342        // Mark queue item as Rendering
1343        if let Some(idx) = self.current_rendering_index {
1344            if idx < self.queue.len() {
1345                self.queue[idx].status = QueueStatus::Rendering;
1346            }
1347        }
1348        let cancel_flag = Arc::new(AtomicBool::new(false));
1349        self.cancel_token = Some(cancel_flag.clone());
1350        let (tx, rx) = mpsc::channel::<ExportEvent>();
1351        self.export_rx = Some(rx);
1352        self.status_message = format!(
1353            "Starting export: {} / {} via {} {} ...",
1354            cs.name(),
1355            tf.name(),
1356            cf.name(),
1357            self.active_profile_name(),
1358        );
1359
1360        let progress_cb = {
1361            let prog_tx = tx.clone();
1362            Arc::new(move |pct: f64| { let _ = prog_tx.send(ExportEvent::Progress(pct)); })
1363        };
1364
1365        let rate_control = self.active_rate_control.clone();
1366        let stats = Arc::new(PipelineStats::new());
1367        let stats_for_event = Arc::clone(&stats);
1368
1369        std::thread::spawn(move || {
1370            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1371                crate::pipeline::run_export(
1372                    info, output_path, progress_cb, cancel_flag, stats,
1373                    cs, tf, cf, pp, dp, hp, h4p, ap, vp,
1374                    hevc_enc, h264_enc, av1_enc, prores_enc,
1375                    rate_control,
1376                )
1377            }));
1378            // Always emit stats before Done so the UI can persist them,
1379            // even on panic/cancel.
1380            let _ = tx.send(ExportEvent::Stats(stats_for_event));
1381            match result {
1382                Ok(export_result) => {
1383                    let _ = tx.send(ExportEvent::Done(export_result));
1384                }
1385                Err(panic) => {
1386                    tracing::error!("export thread panicked: {:?}", panic);
1387                    let _ = tx.send(ExportEvent::Done(Err(anyhow::anyhow!("Export thread panicked"))));
1388                }
1389            }
1390        });
1391    }
1392
1393    pub fn remove_selected_from_media_pool(&mut self) {
1394        let has_selected = self.imported_files.iter().any(|f| f.selected);
1395        if has_selected {
1396            let count = self.imported_files.iter().filter(|f| f.selected).count();
1397            self.imported_files.retain(|f| !f.selected);
1398            if self.media_pool_index >= self.imported_files.len() && !self.imported_files.is_empty() {
1399                self.media_pool_index = self.imported_files.len() - 1;
1400            }
1401            self.status_message = format!("Removed {} selected file(s) from media pool", count);
1402        } else {
1403            self.status_message = "No files selected - use Space to select".to_string();
1404        }
1405    }
1406
1407    pub fn set_export_folder(&mut self, folder: std::path::PathBuf) {
1408        self.export_folder = Some(folder);
1409        self.status_message = format!("Export folder set");
1410    }
1411
1412    pub fn toggle_favourite_folder(&mut self, folder: PathBuf) {
1413        if let Some(pos) = self.favourite_folders.iter().position(|f| f == &folder) {
1414            self.favourite_folders.remove(pos);
1415            self.status_message = "Removed from favourites".to_string();
1416        } else {
1417            self.favourite_folders.push(folder);
1418            self.status_message = "Added to favourites".to_string();
1419        }
1420        self.save_favourites();
1421    }
1422
1423    // -----------------------------------------------------------------------
1424    // Export presets
1425    // -----------------------------------------------------------------------
1426
1427    /// Snapshot the current export settings as a named preset and persist
1428    /// the full preset list to disk. If a preset with the same name already
1429    /// exists it is replaced in place.
1430    pub fn save_current_as_preset(&mut self, name: String) {
1431        let name = name.trim().to_string();
1432        if name.is_empty() {
1433            self.status_message = "Preset name cannot be empty".to_string();
1434            return;
1435        }
1436        let preset = ExportPreset::snapshot(
1437            name.clone(),
1438            self.export_color_space,
1439            self.export_transfer_function,
1440            self.export_codec_family,
1441            self.prores_profile,
1442            self.dnxhr_profile,
1443            self.hevc_profile,
1444            self.h264_profile,
1445            self.av1_profile,
1446            self.vp9_profile,
1447            self.active_rate_control.clone(),
1448            self.export_folder.clone(),
1449        );
1450        ExportPreset::upsert(&mut self.presets, preset);
1451        ExportPreset::save_all(&self.presets);
1452        self.active_preset = Some(name.clone());
1453        self.status_message = format!("Saved preset: {}", name);
1454    }
1455
1456    /// Apply the preset at the given index, copying every field onto the
1457    /// app's live state.
1458    pub fn apply_preset(&mut self, index: usize) {
1459        if index >= self.presets.len() {
1460            return;
1461        }
1462        let p = self.presets[index].clone();
1463        self.export_color_space = p.color_space;
1464        self.export_transfer_function = p.transfer_function;
1465        self.export_codec_family = p.codec_family;
1466        self.prores_profile = p.prores_profile;
1467        self.dnxhr_profile = p.dnxhr_profile;
1468        self.hevc_profile = p.hevc_profile;
1469        self.h264_profile = p.h264_profile;
1470        self.av1_profile = p.av1_profile;
1471        self.vp9_profile = p.vp9_profile;
1472        self.active_rate_control = p.rate_control;
1473        self.export_folder = p.export_folder;
1474        // Exit custom-rate edit mode if the preset isn't a custom rate.
1475        if !matches!(self.active_rate_control, RateControl::Custom(_)) {
1476            self.is_editing_custom_rate = false;
1477        }
1478        self.active_preset = Some(p.name.clone());
1479        self.status_message = format!("Applied preset: {}", p.name);
1480    }
1481
1482    /// Delete the preset at the given index. If that preset was the active
1483    /// one, clear the active marker.
1484    pub fn delete_preset(&mut self, index: usize) {
1485        if index >= self.presets.len() {
1486            return;
1487        }
1488        let removed_name = self.presets[index].name.clone();
1489        self.presets.remove(index);
1490        ExportPreset::save_all(&self.presets);
1491        if self.active_preset.as_deref() == Some(removed_name.as_str()) {
1492            self.active_preset = None;
1493        }
1494        // Keep the cursor in bounds.
1495        if !self.presets.is_empty() && self.preset_picker.index >= self.presets.len() {
1496            self.preset_picker.index = self.presets.len() - 1;
1497        }
1498        self.preset_picker.message = Some(format!("Deleted preset: {}", removed_name));
1499        self.status_message = format!("Deleted preset: {}", removed_name);
1500    }
1501
1502    /// Open the preset picker overlay. If there are no presets, surface a
1503    /// hint in the status bar instead of opening an empty list.
1504    pub fn open_preset_picker(&mut self) {
1505        if self.presets.is_empty() {
1506            self.status_message = "No presets yet — press [p] to save the current settings".to_string();
1507            return;
1508        }
1509        self.preset_picker.open = true;
1510        self.preset_picker.index = self.presets.len().saturating_sub(1).min(self.preset_picker.index);
1511        self.preset_picker.message = None;
1512    }
1513
1514    pub fn close_preset_picker(&mut self) {
1515        self.preset_picker.open = false;
1516        self.preset_picker.message = None;
1517    }
1518
1519    /// Enter the in-line naming mode for a new preset. The user types the
1520    /// name and presses Enter to save.
1521    pub fn begin_naming_preset(&mut self) {
1522        let default_name = match &self.active_preset {
1523            Some(n) => format!("{} (copy)", n),
1524            None => "My Preset".to_string(),
1525        };
1526        self.preset_naming = Some(PresetNamingState { name: default_name, message: None });
1527        self.preset_picker.open = false;
1528    }
1529
1530    pub fn cancel_naming_preset(&mut self) {
1531        self.preset_naming = None;
1532    }
1533
1534    /// Finalize naming: save the preset and exit the naming state.
1535    pub fn commit_naming_preset(&mut self) {
1536        let name = match self.preset_naming.as_ref() {
1537            Some(s) => s.name.clone(),
1538            None => return,
1539        };
1540        self.preset_naming = None;
1541        self.save_current_as_preset(name);
1542    }
1543
1544    /// True if the current settings exactly match the named preset (best
1545    /// effort: only checked for the fields we know about).
1546    pub fn current_matches_preset(&self, name: &str) -> bool {
1547        if let Some(p) = self.presets.iter().find(|p| p.name == name) {
1548            p.color_space == self.export_color_space
1549                && p.transfer_function == self.export_transfer_function
1550                && p.codec_family == self.export_codec_family
1551                && p.prores_profile == self.prores_profile
1552                && p.dnxhr_profile == self.dnxhr_profile
1553                && p.hevc_profile == self.hevc_profile
1554                && p.h264_profile == self.h264_profile
1555                && p.av1_profile == self.av1_profile
1556                && p.vp9_profile == self.vp9_profile
1557                && p.rate_control.name() == self.active_rate_control.name()
1558                && p.export_folder == self.export_folder
1559        } else {
1560            false
1561        }
1562    }
1563
1564    pub fn import_selected_from_browser(&mut self) {
1565        let paths = self.browser.selected_mcraw_paths();
1566        if paths.is_empty() {
1567            self.status_message = "No .mcraw files selected in browser".to_string();
1568            return;
1569        }
1570        let count = paths.len();
1571        let (imported, failed) = self.load_files_batch(&paths);
1572        let msg = if failed > 0 {
1573            format!("Imported {} file(s), {} failed", imported, failed)
1574        } else {
1575            format!("Imported {} file(s)", imported)
1576        };
1577        self.status_message = msg;
1578        // Clear selection checkboxes on imported files
1579        for entry in self.browser.entries.iter_mut() {
1580            if entry.selected && entry.name.to_lowercase().ends_with(".mcraw") {
1581                entry.selected = false;
1582            }
1583        }
1584        if count > 0 {
1585            self.show_browser = false;
1586        }
1587    }
1588
1589    pub fn cancel_export(&mut self) {
1590        if let Some(ref token) = self.cancel_token {
1591            tracing::info!("export cancellation requested");
1592            token.store(true, Ordering::Relaxed);
1593            self.export_cancelled = true;
1594            self.status_message = "Cancelling export...".to_string();
1595        }
1596    }
1597
1598    pub fn poll_export(&mut self) {
1599        let rx = match self.export_rx.take() {
1600            Some(rx) => rx,
1601            None => return,
1602        };
1603        let mut keep_rx = true;
1604        while let Ok(event) = rx.try_recv() {
1605            match event {
1606                ExportEvent::Progress(pct) => {
1607                    self.export_progress = pct;
1608                    if let Some(q) = self.queue.iter_mut().find(|q| matches!(q.status, QueueStatus::Rendering)) {
1609                        q.progress = pct;
1610                    }
1611                }
1612                ExportEvent::Stats(_stats) => {
1613                    // Stats are collected internally for future TUI display
1614                    // (FPS meter, phase timing chart). No terminal output.
1615                }
1616                ExportEvent::Done(result) => {
1617                    self.is_exporting = false;
1618                    keep_rx = false;
1619                    self.cancel_token = None;
1620                    let elapsed = self.export_start_time
1621                        .take()
1622                        .map(|t| t.elapsed())
1623                        .unwrap_or_default();
1624                    // Mark the currently rendering item
1625                    if let Some(idx) = self.current_rendering_index {
1626                        if idx < self.queue.len() {
1627                            self.queue[idx].progress = 100.0;
1628                            if self.export_cancelled {
1629                                self.queue[idx].status = QueueStatus::Waiting;
1630                            } else {
1631                                match &result {
1632                                    Ok(()) => {
1633                                        self.queue[idx].status = QueueStatus::Completed;
1634                                    }
1635                                    Err(e) => {
1636                                        self.queue[idx].status = QueueStatus::Failed(e.to_string());
1637                                    }
1638                                }
1639                            }
1640                        }
1641                    }
1642                    // Build the post-render summary. Always shown (success,
1643                    // failure, or cancellation) so the user can see what
1644                    // ran and for how long.
1645                    if let Some(mut summary) = self.pending_export_summary.take() {
1646                        summary.elapsed = elapsed;
1647                        summary.result = if self.export_cancelled {
1648                            Err("Cancelled by user".to_string())
1649                        } else {
1650                            match &result {
1651                                Ok(()) => Ok(()),
1652                                Err(e) => Err(e.to_string()),
1653                            }
1654                        };
1655                        self.last_export_summary = Some(summary);
1656                    }
1657                    if self.export_cancelled {
1658                        self.status_message = "Export cancelled".to_string();
1659                        self.export_cancelled = false;
1660                        self.current_rendering_index = None;
1661                    } else {
1662                        let mins = elapsed.as_secs() / 60;
1663                        let secs = elapsed.as_secs() % 60;
1664                        match result {
1665                            Ok(()) => {
1666                                tracing::info!("export completed in {:02}m {:02}s", mins, secs);
1667                                self.status_message = format!(
1668                                    "Video export completed ({:02}m {:02}s)", mins, secs
1669                                );
1670                                self.shockwave_ticks_remaining = 30;
1671                            }
1672                            Err(e) => {
1673                                tracing::error!("export failed: {}", e);
1674                                self.status_message = format!("Export failed: {}", e);
1675                            }
1676                        }
1677                        // Auto-start next queued item
1678                        self.start_next_queued_render();
1679                    }
1680                    self.export_start_time = None;
1681                }
1682            }
1683        }
1684        if keep_rx {
1685            self.export_rx = Some(rx);
1686        }
1687    }
1688
1689    pub fn add_encode_job(&mut self, format: OutputFormat) {
1690        let job = EncodeJob::new(uuid::Uuid::new_v4().to_string()[..8].to_string(), format);
1691        self.encode_jobs.push(job);
1692        self.status_message = "Export job added".to_string();
1693    }
1694
1695    // -----------------------------------------------------------------------
1696    // Browser navigation
1697    // -----------------------------------------------------------------------
1698
1699    pub fn select_file(&mut self) {
1700        let entry_data = self.browser.selected_entry().map(|e| (e.is_dir, e.name.clone(), e.path.clone()));
1701        if let Some((is_dir, name, path)) = entry_data {
1702            if is_dir {
1703                self.browser.enter();
1704                self.status_message = format!("Entered: {}", name);
1705                self.show_favourites_bar = false;
1706            } else if name.ends_with(".mcraw") {
1707                let path_str = path.to_string_lossy().to_string();
1708                self.load_file(path_str);
1709                self.show_browser = false;
1710            } else {
1711                self.status_message = format!("Cannot open: {} (not a .mcraw file)", name);
1712            }
1713        }
1714    }
1715
1716    /// Scan a folder for all .mcraw files and return sorted paths
1717    pub fn scan_mcraw_files_in_folder(&self, folder: &str) -> Vec<String> {
1718        if let Ok(entries) = std::fs::read_dir(folder) {
1719            let mut files: Vec<String> = entries
1720                .filter_map(|e| e.ok())
1721                .map(|e| e.path())
1722                .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
1723                .map(|p| p.to_string_lossy().to_string())
1724                .collect();
1725            files.sort();
1726            files
1727        } else {
1728            Vec::new()
1729        }
1730    }
1731
1732    pub fn navigate_browser(&mut self, direction: BrowserDirection) {
1733        match direction {
1734            BrowserDirection::Up => {
1735                self.browser.navigate_up();
1736            }
1737            BrowserDirection::Down => {
1738                self.browser.navigate_down();
1739            }
1740            BrowserDirection::Enter => self.select_file(),
1741            BrowserDirection::GoUp => {
1742                self.browser.go_up();
1743                self.show_favourites_bar = false;
1744            }
1745            BrowserDirection::ToggleHidden => self.browser.toggle_hidden(),
1746        }
1747    }
1748
1749    /// Move the favourites-list cursor by `delta`. Clamps to bounds.
1750    pub fn navigate_favourites(&mut self, delta: i64) {
1751        if self.favourite_folders.is_empty() {
1752            return;
1753        }
1754        let cur = self.favourites_scroll_offset.get() as i64;
1755        let max = (self.favourite_folders.len() as i64) - 1;
1756        let next = (cur + delta).clamp(0, max);
1757        self.favourites_scroll_offset.set(next as usize);
1758    }
1759
1760    /// Navigate into the favourite at the current cursor position.
1761    pub fn open_selected_favourite(&mut self) {
1762        let idx = self.favourites_scroll_offset.get();
1763        if let Some(path) = self.favourite_folders.get(idx).cloned() {
1764            self.status_message = format!("Navigated to favourite: {}", path.display());
1765            self.browser = FileBrowser::from_path(path);
1766            self.browser_scroll_offset = Cell::new(0);
1767            self.browsing_favourites = false;
1768            self.show_favourites_bar = false;
1769        }
1770    }
1771
1772    /// Delete the favourite at the current cursor position.
1773    pub fn delete_selected_favourite(&mut self) {
1774        let idx = self.favourites_scroll_offset.get();
1775        if idx < self.favourite_folders.len() {
1776            let name = self.favourite_folders[idx].display().to_string();
1777            self.favourite_folders.remove(idx);
1778            self.save_favourites();
1779            if self.favourite_folders.is_empty() {
1780                self.browsing_favourites = false;
1781            } else if self.favourites_scroll_offset.get() >= self.favourite_folders.len() {
1782                self.favourites_scroll_offset.set(self.favourite_folders.len() - 1);
1783            }
1784            self.status_message = format!("Removed favourite: {}", name);
1785        }
1786    }
1787
1788    // -----------------------------------------------------------------------
1789    // Focus cycling
1790    // -----------------------------------------------------------------------
1791
1792    pub fn cycle_focus(&mut self) {
1793        self.focus_target = match self.focus_target {
1794            FocusTarget::MediaPool => FocusTarget::Preview,
1795            FocusTarget::Preview => FocusTarget::Grade,
1796            FocusTarget::Grade => FocusTarget::ExportSettings,
1797            FocusTarget::ExportSettings => FocusTarget::Queue,
1798            FocusTarget::Queue => FocusTarget::MediaPool,
1799        };
1800        let label = match self.focus_target {
1801            FocusTarget::MediaPool => "Media Pool",
1802            FocusTarget::Preview => "Preview",
1803            FocusTarget::Grade => "Grade",
1804            FocusTarget::ExportSettings => "Export Settings",
1805            FocusTarget::Queue => "Render Queue",
1806        };
1807        self.status_message = format!("Focus: {}", label);
1808    }
1809
1810    pub fn set_focus(&mut self, target: FocusTarget) {
1811        self.focus_target = target;
1812        let label = match target {
1813            FocusTarget::MediaPool => "Media Pool",
1814            FocusTarget::Preview => "Preview",
1815            FocusTarget::Grade => "Grade",
1816            FocusTarget::ExportSettings => "Export Settings",
1817            FocusTarget::Queue => "Render Queue",
1818        };
1819        self.status_message = format!("Focus: {}", label);
1820    }
1821
1822}
1823
1824fn execute_click_action(app: &mut App, action: ClickAction) {
1825    match action {
1826        ClickAction::ToggleBrowser => {
1827            app.show_browser = !app.show_browser;
1828            app.status_message = if app.show_browser { "Browser shown" } else { "Browser hidden" }.to_string();
1829        }
1830        ClickAction::ToggleFileSelection(i) => {
1831            if let Some(f) = app.imported_files.get_mut(i) {
1832                f.selected = !f.selected;
1833            }
1834        }
1835        ClickAction::ToggleQueueSelection(i) => {
1836            if let Some(q) = app.queue.get_mut(i) {
1837                q.selected = !q.selected;
1838            }
1839        }
1840        ClickAction::SelectMediaPoolItem(i) => {
1841            if i < app.imported_files.len() {
1842                app.media_pool_index = i;
1843                app.set_focus(FocusTarget::MediaPool);
1844            }
1845        }
1846        ClickAction::SelectQueueItem(i) => {
1847            if i < app.queue.len() {
1848                app.queue_index = i;
1849                app.set_focus(FocusTarget::Queue);
1850            }
1851        }
1852        ClickAction::FocusMediaPool => {
1853            app.set_focus(FocusTarget::MediaPool);
1854        }
1855        ClickAction::FocusQueue => {
1856            app.set_focus(FocusTarget::Queue);
1857        }
1858        ClickAction::FocusExport => {
1859            app.set_focus(FocusTarget::ExportSettings);
1860        }
1861        ClickAction::FocusPreview => {
1862            app.set_focus(FocusTarget::Preview);
1863        }
1864        ClickAction::FocusGrade => {
1865            app.show_grade_screen = !app.show_grade_screen;
1866            if app.show_grade_screen {
1867                app.set_focus(FocusTarget::Grade);
1868                app.status_message = "Grade screen — Esc to exit".to_string();
1869            } else {
1870                app.grade_dragging = None;
1871                app.set_focus(FocusTarget::Preview);
1872                app.status_message = "Normal view".to_string();
1873            }
1874        }
1875        ClickAction::AddSelectedToQueue => app.add_selected_to_queue(),
1876        ClickAction::AddAllToQueue => app.add_all_to_queue(),
1877        ClickAction::RemoveSelectedFromMediaPool => app.remove_selected_from_media_pool(),
1878        ClickAction::ToggleBrowserSelection(i) => {
1879            if let Some(entry) = app.browser.entries.get_mut(i) {
1880                if entry.name.to_lowercase().ends_with(".mcraw") {
1881                    entry.selected = !entry.selected;
1882                }
1883            }
1884        }
1885        ClickAction::RenderSelected => app.render_selected(),
1886        ClickAction::RenderAll => app.render_all(),
1887        ClickAction::ClearQueue => app.clear_completed_queue(),
1888        ClickAction::CycleCodec => {
1889            app.set_focus(FocusTarget::ExportSettings);
1890            app.cycle_codec(true);
1891        }
1892        ClickAction::CycleGamut => {
1893            app.set_focus(FocusTarget::ExportSettings);
1894            app.export_focus = ExportFocus::ColorSpace;
1895            app.export_color_space = app.export_color_space.next();
1896            app.status_message = format!("Gamut: {}", app.export_color_space.name());
1897        }
1898        ClickAction::CycleTransfer => {
1899            app.set_focus(FocusTarget::ExportSettings);
1900            app.export_focus = ExportFocus::TransferFunction;
1901            app.export_transfer_function = app.export_transfer_function.next();
1902            app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
1903        }
1904        ClickAction::CycleProfile => {
1905            app.set_focus(FocusTarget::ExportSettings);
1906            app.cycle_profile(true);
1907        }
1908        ClickAction::CycleRate => {
1909            app.set_focus(FocusTarget::ExportSettings);
1910            app.export_focus = ExportFocus::RateControl;
1911            app.cycle_rate_control();
1912        }
1913        ClickAction::ImportOption1 => {
1914            if app.import_popup != ImportPopupState::Hidden {
1915                if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
1916                    let files = files.clone();
1917                    if !files.is_empty() {
1918                        let count = files.len();
1919                        app.status_message = format!("Importing {} file(s)...", count);
1920                        let (imported, failed) = app.load_files_batch(&files);
1921                        if failed > 0 {
1922                            app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1923                        } else {
1924                            app.status_message = format!("Imported {} file(s)", imported);
1925                        }
1926                    }
1927                    app.import_popup = ImportPopupState::Hidden;
1928                    app.show_browser = false;
1929                }
1930            } else if app.show_browser {
1931                app.import_selected_from_browser();
1932            }
1933        }
1934        ClickAction::ImportOption2 => {
1935            if app.import_popup != ImportPopupState::Hidden {
1936                if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
1937                    let all_in_folder = all_in_folder.clone();
1938                    if !all_in_folder.is_empty() {
1939                        let count = all_in_folder.len();
1940                        app.status_message = format!("Importing all {} file(s) from folder...", count);
1941                        let (imported, failed) = app.load_files_batch(&all_in_folder);
1942                        if failed > 0 {
1943                            app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
1944                        } else {
1945                            app.status_message = format!("Imported all {} file(s)", imported);
1946                        }
1947                    }
1948                    app.import_popup = ImportPopupState::Hidden;
1949                    app.show_browser = false;
1950                }
1951            } else if app.show_browser {
1952                let folder = app.browser.current_path.clone();
1953                app.load_all_in_folder(&folder);
1954                app.show_browser = false;
1955            }
1956        }
1957        ClickAction::ClosePopup => { app.import_popup = ImportPopupState::Hidden; }
1958        ClickAction::ToggleHelp => { app.show_help = !app.show_help; }
1959        ClickAction::BrowserNavigate(i) => {
1960            let now = Instant::now();
1961            let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
1962            let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
1963
1964            app.browser.selected_index = i;
1965
1966            if was_same && is_double {
1967                app.select_file();
1968                app.last_browser_click = None;
1969            } else {
1970                app.last_browser_click = Some((now, i));
1971            }
1972        }
1973        ClickAction::BrowserSelectAndEnter(i) => {
1974            let now = Instant::now();
1975            let was_same = app.last_browser_click.as_ref().map(|&(_, idx)| idx == i).unwrap_or(false);
1976            let is_double = app.last_browser_click.as_ref().map(|&(t, _)| now.duration_since(t).as_millis() < 400).unwrap_or(false);
1977
1978            app.browser.selected_index = i;
1979
1980            if was_same && is_double {
1981                app.select_file();
1982                app.last_browser_click = None;
1983            } else {
1984                app.last_browser_click = Some((now, i));
1985            }
1986        }
1987        ClickAction::BrowserEnter => {
1988            app.navigate_browser(BrowserDirection::Enter);
1989        }
1990        ClickAction::BrowserGoUp => {
1991            app.navigate_browser(BrowserDirection::GoUp);
1992        }
1993        ClickAction::FavouriteNavigate(i) => {
1994            if i < app.favourite_folders.len() {
1995                let path = app.favourite_folders[i].clone();
1996                app.browser = FileBrowser::from_path(path);
1997                app.browser_scroll_offset = Cell::new(0);
1998                app.show_favourites_bar = false;
1999                app.last_clicked_favourite = Some((Instant::now(), i));
2000                app.status_message = "Navigated to favourite folder".to_string();
2001            }
2002        }
2003        ClickAction::OpenPresetPicker => {
2004            app.open_preset_picker();
2005        }
2006        ClickAction::GradeSlider(i) => {
2007            app.grade_focus = i;
2008            app.set_focus(FocusTarget::Grade);
2009        }
2010    }
2011}
2012
2013pub enum BrowserDirection {
2014    Up,
2015    Down,
2016    Enter,
2017    GoUp,
2018    ToggleHidden,
2019}
2020
2021pub async fn run(args: Cli) -> Result<()> {
2022    let mut app = App::new();
2023    tracing::info!("app initialized: hardware_caps={:?}", app.hardware_caps);
2024
2025    match args.resolve() {
2026        ResolvedCli::Command(CliCommands::Open { file }) => {
2027            if let Some(path) = file {
2028                app.load_file(path);
2029            }
2030        }
2031        ResolvedCli::Command(CliCommands::Info { file }) => {
2032            let path = match file {
2033                Some(p) => p,
2034                None => return Err(anyhow::anyhow!("No file specified")),
2035            };
2036            match McrawFileInfo::from_path(&path) {
2037                Ok(mut info) => {
2038                    info.enhance_with_decoder();
2039                    return Ok(());
2040                }
2041                Err(e) => return Err(e),
2042            }
2043        }
2044        ResolvedCli::Command(CliCommands::Export { file, format, output }) => {
2045            if file.is_none() {
2046                return Err(anyhow::anyhow!("No file specified"));
2047            }
2048            if let Err(e) = Cli::validate_export_format(&format) {
2049                anyhow::bail!("{}", e);
2050            }
2051            let format = match format.to_lowercase().as_str() {
2052                "dng" => OutputFormat::DNG { output_path: std::path::PathBuf::from(&output) },
2053                "prores" => OutputFormat::ProRes { output_path: std::path::PathBuf::from(&output) },
2054                "h264" => OutputFormat::H264 { output_path: std::path::PathBuf::from(&output) },
2055                "hevc" => OutputFormat::HEVC { output_path: std::path::PathBuf::from(&output) },
2056                _ => anyhow::bail!("Invalid format: {}", format),
2057            };
2058
2059            let encoder = Encoder::new();
2060            let mut job = EncodeJob::new("cli-export".to_string(), format.clone());
2061            job.status = EncodeStatus::Running;
2062
2063            match encoder.start_job(job.clone()).await {
2064                Ok(()) => { job.status = EncodeStatus::Completed; }
2065                Err(e) => { job.status = EncodeStatus::Failed(e.to_string()); }
2066            }
2067            return Ok(());
2068        }
2069        ResolvedCli::NoFile => {
2070            app.status_message = "No file specified. Use: mcraw-tui -f <path>".to_string();
2071        }
2072    }
2073
2074    let stdout = std::io::stdout();
2075    let backend = CrosstermBackend::new(stdout);
2076    let mut terminal = ratatui::Terminal::new(backend)?;
2077    terminal.clear()?;
2078    crossterm::execute!(
2079        std::io::stdout(),
2080        EnterAlternateScreen,
2081        EnableBracketedPaste,
2082        EnableMouseCapture,
2083    )?;
2084    terminal.hide_cursor()?;
2085
2086    enable_raw_mode()?;
2087    tracing::info!("terminal initialized: alternate_screen, bracketed_paste, mouse_capture enabled");
2088
2089    let event_loop_running = Arc::new(AtomicBool::new(true));
2090    let elr = event_loop_running.clone();
2091
2092    let (tx, rx) = mpsc::channel();
2093    tokio::spawn(async move {
2094        event_loop(tx, elr).await;
2095    });
2096
2097    let encoder = Encoder::new();
2098    tracing::info!("entering main event loop");
2099
2100    while app.running {
2101        app.poll_export();
2102        app.poll_drop_import();
2103        app.browser.try_refresh();
2104
2105        // Record render timestamp BEFORE drawing so the FPS meter includes
2106        // the draw and sleep overhead, giving a realistic "frames the user
2107        // actually sees" reading.
2108        app.fps_counter.tick();
2109
2110        let mut click_regions = Vec::new();
2111        terminal.draw(|frame| ui::render(frame, &app, &mut click_regions))?;
2112
2113        // Advance animation state
2114        app.spinner_frame = app.spinner_frame.wrapping_add(1);
2115        // Slow the dither animation to ~800ms cycle (every 4th tick)
2116        if app.spinner_frame % 4 == 0 {
2117            app.progress_anim_offset = app.progress_anim_offset.wrapping_add(1);
2118        }
2119        if app.shockwave_ticks_remaining > 0 {
2120            app.shockwave_ticks_remaining -= 1;
2121        }
2122        // Decay grade morph animation
2123        if let Some((_, ref mut t)) = app.grade_morph {
2124            *t = t.saturating_sub(1);
2125            if *t == 0 { app.grade_morph = None; }
2126        }
2127        // Decay phosphor trail
2128        app.phosphor_trail.iter_mut().for_each(|(_, t)| *t = t.saturating_sub(1));
2129        app.phosphor_trail.retain(|(_, t)| *t > 0);
2130        // Decay focus strip idle counter
2131        if app.grade_strip_idle_ticks > 0 {
2132            app.grade_strip_idle_ticks = app.grade_strip_idle_ticks.saturating_sub(1);
2133        } else if app.show_grade_screen {
2134            app.grade_strip_active = false;
2135        }
2136
2137        // Drain ALL pending events each frame — critical for drag-drop where
2138        // the terminal sends a burst of events that must be consumed together.
2139        // Processing only one per frame causes input lag and wrong key events
2140        // leaking through between paste characters.
2141        while let Ok(event) = rx.try_recv() {
2142            handle_event(&mut app, event, &encoder, &click_regions).await;
2143        }
2144
2145        time::sleep(Duration::from_millis(16)).await;
2146    }
2147
2148    event_loop_running.store(false, Ordering::Relaxed);
2149    drop(rx);
2150    tokio::task::yield_now().await;
2151
2152    disable_raw_mode()?;
2153    terminal.show_cursor()?;
2154    crossterm::execute!(
2155        std::io::stdout(),
2156        DisableMouseCapture,
2157        DisableBracketedPaste,
2158        LeaveAlternateScreen,
2159    )?;
2160    tracing::info!("terminal shutdown: raw_mode disabled, screen restored");
2161
2162    Ok(())
2163}
2164
2165async fn event_loop(tx: mpsc::Sender<Event>, running: Arc<AtomicBool>) {
2166    tracing::debug!("event_loop started");
2167    while running.load(Ordering::Relaxed) {
2168        if crossterm::event::poll(Duration::from_millis(8)).unwrap() {
2169            if let Ok(event) = crossterm::event::read() {
2170                if tx.send(event).is_err() {
2171                    break;
2172                }
2173            }
2174        }
2175    }
2176}
2177
2178// ---------------------------------------------------------------------------
2179// Drag-drop path parsing helpers
2180// ---------------------------------------------------------------------------
2181
2182/// Strip surrounding quotes from a path string (handles nested quotes).
2183fn strip_surrounding_quotes(s: &str) -> String {
2184    let s = s.trim();
2185    if s.len() >= 2 {
2186        let first = s.chars().next().unwrap();
2187        let last = s.chars().last().unwrap();
2188        if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
2189            return s[1..s.len() - 1].to_string();
2190        }
2191    }
2192    s.to_string()
2193}
2194
2195/// Expand ~ to home directory.
2196fn expand_tilde(s: &str) -> String {
2197    if s == "~" {
2198        if let Some(home) = dirs::home_dir() {
2199            return home.to_string_lossy().to_string();
2200        }
2201    }
2202    if let Some(rest) = s.strip_prefix("~/") {
2203        if let Some(home) = dirs::home_dir() {
2204            return home.join(rest).to_string_lossy().to_string();
2205        }
2206    }
2207    s.to_string()
2208}
2209
2210/// Decode file:// URIs to native paths.
2211/// Handles file:///C:/... (Windows) and file:///home/... (Unix).
2212fn decode_file_uri(s: &str) -> String {
2213    if let Some(rest) = s.strip_prefix("file:///") {
2214        // file:///C:/path → C:/path (Windows) or file:///home → /home (Unix)
2215        if cfg!(windows) && rest.len() >= 2 {
2216            let chars: Vec<char> = rest.chars().collect();
2217            if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' {
2218                return rest.to_string();
2219            }
2220        }
2221        // Unix: file:///home/user → /home/user
2222        return format!("/{}", rest);
2223    }
2224    if let Some(rest) = s.strip_prefix("file://") {
2225        // file://hostname/path (network paths) — strip hostname
2226        if let Some(slash_pos) = rest.find('/') {
2227            return rest[slash_pos..].to_string();
2228        }
2229        return rest.to_string();
2230    }
2231    s.to_string()
2232}
2233
2234/// Percent-decode URI-encoded characters (e.g. %20 → space, %C3%A9 → é).
2235fn percent_decode_path(s: &str) -> String {
2236    if !s.contains('%') {
2237        return s.to_string();
2238    }
2239    match percent_decode_str(s).decode_utf8() {
2240        Ok(decoded) => decoded.into_owned(),
2241        Err(_) => s.to_string(), // Fall back to original if decoding fails
2242    }
2243}
2244
2245/// Normalize path separators for the current platform.
2246fn normalize_path(s: &str) -> String {
2247    if cfg!(windows) {
2248        // Preserve UNC paths (\\server\share)
2249        if s.starts_with("\\\\") {
2250            return s.to_string();
2251        }
2252        // Convert forward slashes to backslashes
2253        s.replace('/', "\\")
2254    } else {
2255        s.to_string()
2256    }
2257}
2258
2259/// Validate and canonicalize a path. Returns None if path doesn't exist.
2260fn validate_path(s: &str) -> Option<String> {
2261    let path = std::path::Path::new(s);
2262
2263    // Check if path exists
2264    if !path.exists() {
2265        tracing::debug!("path validation: does not exist: {}", s);
2266        return None;
2267    }
2268
2269    // Try to canonicalize (resolves symlinks and normalizes)
2270    // Fall back to original if canonicalization fails
2271    match path.canonicalize() {
2272        Ok(canonical) => Some(canonical.to_string_lossy().to_string()),
2273        Err(_) => {
2274            tracing::debug!("path validation: canonicalize failed, using original: {}", s);
2275            Some(s.to_string())
2276        }
2277    }
2278}
2279
2280async fn handle_event(app: &mut App, event: Event, _encoder: &Encoder, click_regions: &[ui::ClickRegion]) {
2281    match event {
2282        // -------------------------------------------------------------------
2283        // Drag & Drop: pasted file paths
2284        // -------------------------------------------------------------------
2285        Event::Paste(pasted) => {
2286            tracing::trace!("drag-drop: raw pasted bytes={:?} len={}", pasted.as_bytes(), pasted.len());
2287
2288            let paths: Vec<String> = pasted
2289                .lines()
2290                .filter_map(|line| {
2291                    let line = line.trim();
2292                    if line.is_empty() {
2293                        return None;
2294                    }
2295
2296                    // Strip surrounding quotes (handles "path with spaces")
2297                    let stripped = strip_surrounding_quotes(line);
2298
2299                    // Expand ~ to home directory
2300                    let expanded = expand_tilde(&stripped);
2301
2302                    // Decode file:// URI if present
2303                    let decoded = decode_file_uri(&expanded);
2304
2305                    // Percent-decode URI-encoded characters (e.g. %20 → space, %C3%A9 → é)
2306                    let percent_decoded = percent_decode_path(&decoded);
2307
2308                    // Platform-specific path normalization
2309                    let normalized = normalize_path(&percent_decoded);
2310
2311                    // Validate path exists and canonicalize
2312                    validate_path(&normalized)
2313                })
2314                .collect();
2315
2316            tracing::trace!("drag-drop: parsed {} paths: {:?}", paths.len(), paths);
2317
2318            if paths.is_empty() {
2319                app.status_message = "Drag-drop: no valid paths received".to_string();
2320                return;
2321            }
2322
2323            // Separate .mcraw files and directories
2324            let mut mcraw_files: Vec<String> = Vec::new();
2325            let mut folders: Vec<String> = Vec::new();
2326
2327            for p in &paths {
2328                let path = std::path::Path::new(p);
2329                if path.is_dir() {
2330                    folders.push(p.clone());
2331                } else if p.to_lowercase().ends_with(".mcraw") {
2332                    mcraw_files.push(p.clone());
2333                }
2334            }
2335
2336            // If folders were dropped, scan them for .mcraw files
2337            for folder in &folders {
2338                if let Ok(entries) = std::fs::read_dir(folder) {
2339                    let mut files: Vec<String> = entries
2340                        .filter_map(|e| e.ok())
2341                        .map(|e| e.path())
2342                        .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2343                        .map(|p| p.to_string_lossy().to_string())
2344                        .collect();
2345                    files.sort();
2346                    mcraw_files.extend(files);
2347                }
2348            }
2349
2350            // Deduplicate while preserving order
2351            let mut seen = std::collections::HashSet::new();
2352            mcraw_files.retain(|f| seen.insert(f.clone()));
2353
2354            tracing::info!("drag-drop: {} .mcraw files, {} folders", mcraw_files.len(), folders.len());
2355
2356            if mcraw_files.is_empty() {
2357                app.status_message = "Drag-drop: no .mcraw files found in dropped items".to_string();
2358                return;
2359            }
2360
2361            // Trigger visual feedback
2362            app.drop_highlight = Some(Instant::now());
2363
2364            // Smart import: instant for small batches, async for larger ones
2365            // Threshold: <= 3 files = async (smooth UI), > 3 = popup for confirmation
2366            const ASYNC_THRESHOLD: usize = 3;
2367
2368            if mcraw_files.len() <= ASYNC_THRESHOLD && folders.is_empty() {
2369                // Small batch: use async import for smooth UI
2370                app.start_async_import(mcraw_files);
2371            } else {
2372                // Large batch or folders: show import popup
2373                // Check if single file is alone in its folder
2374                if mcraw_files.len() == 1 {
2375                    let file = &mcraw_files[0];
2376                    let folder = std::path::Path::new(file)
2377                        .parent()
2378                        .map(|p| p.to_string_lossy().to_string())
2379                        .unwrap_or_else(|| ".".to_string());
2380
2381                    let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2382                        let mut files: Vec<String> = entries
2383                            .filter_map(|e| e.ok())
2384                            .map(|e| e.path())
2385                            .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2386                            .map(|p| p.to_string_lossy().to_string())
2387                            .collect();
2388                        files.sort();
2389                        files
2390                    } else {
2391                        Vec::new()
2392                    };
2393
2394                    // Only skip popup if this is truly the only .mcraw in the folder
2395                    if all_in_folder.len() == 1 {
2396                        app.start_async_import(mcraw_files);
2397                        return;
2398                    }
2399                }
2400
2401                // Determine the primary folder for the import popup
2402                let folder = if !folders.is_empty() {
2403                    folders[0].clone()
2404                } else {
2405                    std::path::Path::new(&mcraw_files[0])
2406                        .parent()
2407                        .map(|p| p.to_string_lossy().to_string())
2408                        .unwrap_or_else(|| ".".to_string())
2409                };
2410
2411                // Scan ALL .mcraw files in the primary folder
2412                let all_in_folder: Vec<String> = if let Ok(entries) = std::fs::read_dir(&folder) {
2413                    let mut files: Vec<String> = entries
2414                        .filter_map(|e| e.ok())
2415                        .map(|e| e.path())
2416                        .filter(|p| p.extension().map_or(false, |ext| ext.to_ascii_lowercase() == "mcraw"))
2417                        .map(|p| p.to_string_lossy().to_string())
2418                        .collect();
2419                    files.sort();
2420                    files
2421                } else {
2422                    Vec::new()
2423                };
2424
2425                // Show import popup
2426                app.import_popup = ImportPopupState::DroppedFiles {
2427                    files: mcraw_files,
2428                    folder,
2429                    all_in_folder,
2430                };
2431            }
2432        }
2433
2434        // -------------------------------------------------------------------
2435        // Mouse events
2436        // -------------------------------------------------------------------
2437        Event::Mouse(mouse_event) => {
2438            use crossterm::event::{MouseEventKind, MouseButton};
2439
2440            // Allow mouse on import popup (has its own click regions)
2441            if app.import_popup != ImportPopupState::Hidden {
2442                let col = mouse_event.column;
2443                let row = mouse_event.row;
2444                match mouse_event.kind {
2445                    MouseEventKind::Down(MouseButton::Left) => {
2446                        for region in click_regions.iter().rev() {
2447                            if col >= region.area.x && col < region.area.x + region.area.width
2448                                && row >= region.area.y && row < region.area.y + region.area.height {
2449                                match &region.action {
2450                                    ClickAction::ImportOption1 | ClickAction::ImportOption2 => {
2451                                        execute_click_action(app, region.action.clone());
2452                                    }
2453                                    _ => {}
2454                                }
2455                                break;
2456                            }
2457                        }
2458                    }
2459                    _ => {}
2460                }
2461                return;
2462            }
2463
2464            // Block mouse events when full info overlay is active
2465            if app.show_full_info {
2466                return;
2467            }
2468
2469            match mouse_event.kind {
2470                MouseEventKind::ScrollUp => {
2471                    if app.show_help {
2472                        app.help_scroll = app.help_scroll.saturating_sub(1);
2473                    } else if app.show_browser {
2474                        if app.browsing_favourites {
2475                            app.navigate_favourites(-1);
2476                        } else if app.browser.selected_index > 0 {
2477                            app.browser.selected_index -= 1;
2478                        }
2479                    } else {
2480                        match app.focus_target {
2481                            FocusTarget::MediaPool => { if app.media_pool_index > 0 { app.media_pool_index -= 1; } }
2482                            FocusTarget::Queue => { if app.queue_index > 0 { app.queue_index -= 1; } }
2483                            FocusTarget::ExportSettings => {
2484                                // Cycle VALUES of the currently focused setting
2485                                match app.export_focus {
2486                                    ExportFocus::CodecFamily => app.cycle_codec(false),
2487                                    ExportFocus::ColorSpace => {
2488                                        app.export_color_space = app.export_color_space.prev();
2489                                        app.status_message = format!("Gamut: {}", app.export_color_space.name());
2490                                    }
2491                                    ExportFocus::TransferFunction => {
2492                                        app.export_transfer_function = app.export_transfer_function.prev();
2493                                        app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2494                                    }
2495                                    ExportFocus::Profile => app.cycle_profile(false),
2496                                    ExportFocus::RateControl => {
2497                                        app.active_rate_control = app.active_rate_control.prev();
2498                                        app.status_message = format!("Rate: {}", app.active_rate_control.name());
2499                                    }
2500                                }
2501                            }
2502                            FocusTarget::Preview => {}
2503                            FocusTarget::Grade => {
2504                                let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2505                                    GradeSliders::step_large(app.grade_focus)
2506                                } else {
2507                                    GradeSliders::step_small(app.grade_focus)
2508                                };
2509                                app.grade_sliders.apply_delta(app.grade_focus, step);
2510                            }
2511                        }
2512                    }
2513                }
2514                MouseEventKind::ScrollDown => {
2515                    if app.show_help {
2516                        app.help_scroll = app.help_scroll.saturating_add(1);
2517                    } else if app.show_browser {
2518                        if app.browsing_favourites {
2519                            app.navigate_favourites(1);
2520                        } else {
2521                            let len = app.browser.entries.len();
2522                            if len > 0 { app.browser.selected_index = (app.browser.selected_index + 1).min(len - 1); }
2523                        }
2524                    } else {
2525                        match app.focus_target {
2526                            FocusTarget::MediaPool => {
2527                                let len = app.imported_files.len();
2528                                if len > 0 { app.media_pool_index = (app.media_pool_index + 1).min(len - 1); }
2529                            }
2530                            FocusTarget::Queue => {
2531                                let len = app.queue.len();
2532                                if len > 0 { app.queue_index = (app.queue_index + 1).min(len - 1); }
2533                            }
2534                            FocusTarget::ExportSettings => {
2535                                match app.export_focus {
2536                                    ExportFocus::CodecFamily => app.cycle_codec(true),
2537                                    ExportFocus::ColorSpace => {
2538                                        app.export_color_space = app.export_color_space.next();
2539                                        app.status_message = format!("Gamut: {}", app.export_color_space.name());
2540                                    }
2541                                    ExportFocus::TransferFunction => {
2542                                        app.export_transfer_function = app.export_transfer_function.next();
2543                                        app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2544                                    }
2545                                    ExportFocus::Profile => app.cycle_profile(true),
2546                                    ExportFocus::RateControl => app.cycle_rate_control(),
2547                                }
2548                            }
2549                            FocusTarget::Preview => {}
2550                            FocusTarget::Grade => {
2551                                let step = if mouse_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
2552                                    GradeSliders::step_large(app.grade_focus)
2553                                } else {
2554                                    GradeSliders::step_small(app.grade_focus)
2555                                };
2556                                app.grade_sliders.apply_delta(app.grade_focus, -step);
2557                            }
2558                        }
2559                    }
2560                }
2561                MouseEventKind::Down(MouseButton::Left) => {
2562                    let col = mouse_event.column;
2563                    let row = mouse_event.row;
2564                    for region in click_regions.iter().rev() {
2565                        if col >= region.area.x && col < region.area.x + region.area.width
2566                            && row >= region.area.y && row < region.area.y + region.area.height {
2567                            match &region.action {
2568                                ClickAction::GradeSlider(i) => {
2569                                    let now = Instant::now();
2570                                    let is_double = app.last_grade_click.as_ref()
2571                                        .map(|&(t, idx)| idx == *i && now.duration_since(t).as_millis() < 400)
2572                                        .unwrap_or(false);
2573                                    if is_double {
2574                                        // Double-click: reset to default
2575                                        let def = GradeSliders::default_val(*i);
2576                                        app.grade_sliders.set(*i, def);
2577                                        app.last_grade_click = None;
2578                                        app.status_message = format!("Reset {} to default", GradeSliders::name(*i));
2579                                    } else {
2580                                        // Single click: set value from x position + start drag
2581                                        let x_offset = col.saturating_sub(region.area.x);
2582                                        let norm = (x_offset as f32 / region.area.width.max(1) as f32).clamp(0.0, 1.0);
2583                                        let lo = GradeSliders::min(*i);
2584                                        let hi = GradeSliders::max(*i);
2585                                        app.grade_sliders.set(*i, lo + norm * (hi - lo));
2586                                        app.grade_focus = *i;
2587                                        app.grade_dragging = Some((*i, region.area.x, region.area.width));
2588                                        app.last_grade_click = Some((now, *i));
2589                                    }
2590                                }
2591                                _ => execute_click_action(app, region.action.clone()),
2592                            }
2593                            break;
2594                        }
2595                    }
2596                }
2597                MouseEventKind::Drag(MouseButton::Left) => {
2598                    if let Some((i, track_x, track_w)) = app.grade_dragging {
2599                        let col = mouse_event.column;
2600                        let x_offset = col.saturating_sub(track_x);
2601                        let norm = (x_offset as f32 / track_w.max(1) as f32).clamp(0.0, 1.0);
2602                        let lo = GradeSliders::min(i);
2603                        let hi = GradeSliders::max(i);
2604                        app.grade_sliders.set(i, lo + norm * (hi - lo));
2605                    }
2606                }
2607                MouseEventKind::Up(MouseButton::Left) => {
2608                    app.grade_dragging = None;
2609                }
2610                _ => {}
2611            }
2612        }
2613
2614        // -------------------------------------------------------------------
2615        // Keyboard events
2616        // -------------------------------------------------------------------
2617        Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
2618            if let crossterm::event::KeyCode::Char('c') = key_event.code {
2619                if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2620                    tracing::info!("ctrl+c received, quitting");
2621                    app.running = false;
2622                    return;
2623                }
2624            }
2625            // Ctrl+X cancels an in-progress export. Outside of an export it
2626            // is a no-op so it never accidentally trashes the queue.
2627            if let crossterm::event::KeyCode::Char('x') = key_event.code {
2628                if key_event.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
2629                    if app.is_exporting {
2630                        tracing::info!("ctrl+x received, cancelling export");
2631                        app.cancel_export();
2632                    }
2633                    return;
2634                }
2635            }
2636
2637            tracing::debug!("key event: code={:?} modifiers={:?}", key_event.code, key_event.modifiers);
2638
2639            // ----------------------------------------------------------------
2640            // Preset naming (inline text entry)
2641            // ----------------------------------------------------------------
2642            if app.preset_naming.is_some() {
2643                let naming = app.preset_naming.clone().unwrap();
2644                match key_event.code {
2645                    crossterm::event::KeyCode::Char(c) => {
2646                        if let Some(state) = app.preset_naming.as_mut() {
2647                            state.name.push(c);
2648                        }
2649                    }
2650                    crossterm::event::KeyCode::Backspace => {
2651                        if let Some(state) = app.preset_naming.as_mut() {
2652                            state.name.pop();
2653                        }
2654                    }
2655                    crossterm::event::KeyCode::Enter => {
2656                        app.commit_naming_preset();
2657                    }
2658                    crossterm::event::KeyCode::Esc => {
2659                        app.cancel_naming_preset();
2660                        app.status_message = "Preset save cancelled".to_string();
2661                    }
2662                    _ => {}
2663                }
2664                let _ = naming; // Silence unused warning if not used.
2665                return;
2666            }
2667
2668            // ----------------------------------------------------------------
2669            // Preset picker overlay
2670            // ----------------------------------------------------------------
2671            if app.preset_picker.open {
2672                match key_event.code {
2673                    crossterm::event::KeyCode::Esc => app.close_preset_picker(),
2674                    crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
2675                        if app.preset_picker.index > 0 {
2676                            app.preset_picker.index -= 1;
2677                        }
2678                        app.preset_picker.message = None;
2679                    }
2680                    crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
2681                        if app.preset_picker.index + 1 < app.presets.len() {
2682                            app.preset_picker.index += 1;
2683                        }
2684                        app.preset_picker.message = None;
2685                    }
2686                    crossterm::event::KeyCode::Enter => {
2687                        let idx = app.preset_picker.index;
2688                        app.close_preset_picker();
2689                        app.apply_preset(idx);
2690                    }
2691                    crossterm::event::KeyCode::Delete | crossterm::event::KeyCode::Backspace => {
2692                        let idx = app.preset_picker.index;
2693                        app.delete_preset(idx);
2694                    }
2695                    _ => {}
2696                }
2697                return;
2698            }
2699
2700            // ----------------------------------------------------------------
2701            // Import popup
2702            // ----------------------------------------------------------------
2703            if app.import_popup != ImportPopupState::Hidden {
2704                let has_option2 = if let ImportPopupState::DroppedFiles { files, all_in_folder, .. } = &app.import_popup {
2705                    all_in_folder.len() > files.len()
2706                } else {
2707                    false
2708                };
2709
2710                match key_event.code {
2711                    crossterm::event::KeyCode::Char('1') => {
2712                        let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2713                            files.clone()
2714                        } else {
2715                            Vec::new()
2716                        };
2717                        if !files.is_empty() {
2718                            let count = files.len();
2719                            app.status_message = format!("Importing {} file(s)...", count);
2720                            let (imported, failed) = app.load_files_batch(&files);
2721                            if failed > 0 {
2722                                app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2723                            } else {
2724                                app.status_message = format!("Imported {} file(s)", imported);
2725                            }
2726                        }
2727                        app.import_popup = ImportPopupState::Hidden;
2728                        app.show_browser = false;
2729                    }
2730                    crossterm::event::KeyCode::Char('2') if has_option2 => {
2731                        let all_in_folder = if let ImportPopupState::DroppedFiles { all_in_folder, .. } = &app.import_popup {
2732                            all_in_folder.clone()
2733                        } else {
2734                            Vec::new()
2735                        };
2736                        if !all_in_folder.is_empty() {
2737                            let count = all_in_folder.len();
2738                            app.status_message = format!("Importing all {} file(s) from folder...", count);
2739                            let (imported, failed) = app.load_files_batch(&all_in_folder);
2740                            if failed > 0 {
2741                                app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2742                            } else {
2743                                app.status_message = format!("Imported all {} file(s)", imported);
2744                            }
2745                        }
2746                        app.import_popup = ImportPopupState::Hidden;
2747                        app.show_browser = false;
2748                    }
2749                    crossterm::event::KeyCode::Enter => {
2750                        let files = if let ImportPopupState::DroppedFiles { files, .. } = &app.import_popup {
2751                            files.clone()
2752                        } else {
2753                            Vec::new()
2754                        };
2755                        if !files.is_empty() {
2756                            let count = files.len();
2757                            app.status_message = format!("Importing {} file(s)...", count);
2758                            let (imported, failed) = app.load_files_batch(&files);
2759                            if failed > 0 {
2760                                app.status_message = format!("Imported {} file(s), {} failed", imported, failed);
2761                            } else {
2762                                app.status_message = format!("Imported {} file(s)", imported);
2763                            }
2764                        }
2765                        app.import_popup = ImportPopupState::Hidden;
2766                        app.show_browser = false;
2767                    }
2768                    crossterm::event::KeyCode::Esc => {
2769                        app.import_popup = ImportPopupState::Hidden;
2770                    }
2771                    _ => {}
2772                }
2773                return;
2774            }
2775
2776            // ----------------------------------------------------------------
2777            // Custom rate inline editing
2778            // ----------------------------------------------------------------
2779            if app.is_editing_custom_rate {
2780                match key_event.code {
2781                    crossterm::event::KeyCode::Char(c) => {
2782                        if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == 'M' || c == 'k' || c == 'm' {
2783                            if let RateControl::Custom(ref mut val) = app.active_rate_control {
2784                                val.push(c);
2785                            }
2786                        }
2787                    }
2788                    crossterm::event::KeyCode::Backspace => {
2789                        if let RateControl::Custom(ref mut val) = app.active_rate_control {
2790                            val.pop();
2791                        }
2792                    }
2793                    crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Esc => {
2794                        app.is_editing_custom_rate = false;
2795                        app.status_message = format!("Rate: {}", app.active_rate_control.name());
2796                    }
2797                    _ => {}
2798                }
2799                return;
2800            }
2801
2802            // ----------------------------------------------------------------
2803            // Normal character-key dispatch
2804            // ----------------------------------------------------------------
2805            if let crossterm::event::KeyCode::Char(c) = key_event.code {
2806                match c {
2807                    'q' => {
2808                        app.running = false;
2809                    }
2810                    '?' => {
2811                        app.show_help = !app.show_help;
2812                    }
2813                    'b' => {
2814                        // In grade mode, 'b' does before/after; otherwise browser toggle
2815                        if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
2816                            if app.grade_before_snapshot.is_none() {
2817                                app.grade_before_snapshot = Some(app.grade_sliders);
2818                                app.grade_sliders = GradeSliders::default();
2819                                app.shockwave_ticks_remaining = 8;
2820                                app.status_message = "BEFORE — holding original values".to_string();
2821                            }
2822                        } else {
2823                            app.show_browser = !app.show_browser;
2824                            app.status_message = if app.show_browser {
2825                                "Browser shown"
2826                            } else {
2827                                "Browser hidden"
2828                            }.to_string();
2829                        }
2830                    }
2831                    'B' => {
2832                        // Release before/after: restore snapshot
2833                        if let Some(snap) = app.grade_before_snapshot.take() {
2834                            app.grade_sliders = snap;
2835                            app.shockwave_ticks_remaining = 5;
2836                            app.status_message = "AFTER — restored grade".to_string();
2837                        }
2838                    }
2839                    'e' => {
2840                        app.set_focus(FocusTarget::ExportSettings);
2841                    }
2842                    'a' => {
2843                        app.add_selected_to_queue();
2844                    }
2845                    'A' => {
2846                        app.add_all_to_queue();
2847                    }
2848                    'D' => {
2849                        if app.focus_target == FocusTarget::MediaPool {
2850                            app.remove_selected_from_media_pool();
2851                        }
2852                    }
2853                    'd' => {
2854                        // Remove the last-clicked favourite (within 2 seconds)
2855                        if app.show_browser && app.show_favourites_bar {
2856                            if let Some((ts, idx)) = app.last_clicked_favourite.take() {
2857                                if ts.elapsed() < Duration::from_secs(2) && idx < app.favourite_folders.len() {
2858                                    app.favourite_folders.remove(idx);
2859                                    app.status_message = "Removed from favourites".to_string();
2860                                    app.save_favourites();
2861                                    return;
2862                                }
2863                            }
2864                        }
2865                        match app.focus_target {
2866                            FocusTarget::MediaPool => app.remove_from_media_pool(),
2867                            FocusTarget::Queue => app.remove_from_queue(),
2868                            FocusTarget::ExportSettings => {}
2869                            FocusTarget::Preview => {}
2870                            FocusTarget::Grade => {}
2871                        }
2872                    }
2873                    'x' => {
2874                        // When an export is running, `x` (and Ctrl+X) cancel it.
2875                        // Otherwise it clears completed/failed items from the queue.
2876                        if app.is_exporting {
2877                            app.cancel_export();
2878                        } else {
2879                            app.clear_completed_queue();
2880                        }
2881                    }
2882                    'X' => {
2883                        if app.is_exporting {
2884                            app.cancel_export();
2885                        } else {
2886                            app.clear_completed_queue();
2887                        }
2888                    }
2889                    'v' => {
2890                        app.render_selected();
2891                    }
2892                    'R' => {
2893                        app.render_all();
2894                    }
2895                    'r' => {
2896                        if app.show_grade_screen || app.focus_target == FocusTarget::Grade {
2897                            let def = GradeSliders::default_val(app.grade_focus);
2898                            app.grade_sliders.set(app.grade_focus, def);
2899                            app.status_message = format!("Reset {} to default", GradeSliders::name(app.grade_focus));
2900                            app.grade_strip_active = true;
2901                            app.grade_strip_idle_ticks = 15;
2902                        } else if app.focus_target == FocusTarget::ExportSettings {
2903                            app.export_focus = ExportFocus::RateControl;
2904                            app.cycle_rate_control();
2905                        }
2906                    }
2907                    't' => {
2908                        if app.focus_target == FocusTarget::ExportSettings {
2909                            app.export_focus = ExportFocus::TransferFunction;
2910                            app.export_transfer_function = app.export_transfer_function.next();
2911                            app.status_message = format!("Transfer: {}", app.export_transfer_function.name());
2912                        }
2913                    }
2914                    'g' => {
2915                        if app.focus_target == FocusTarget::ExportSettings {
2916                            app.export_focus = ExportFocus::ColorSpace;
2917                            app.export_color_space = app.export_color_space.next();
2918                            app.status_message = format!("Gamut: {}", app.export_color_space.name());
2919                        }
2920                    }
2921                    'c' => {
2922                        if app.focus_target == FocusTarget::ExportSettings {
2923                            app.cycle_codec(true);
2924                        }
2925                    }
2926                    'o' => {
2927                        if app.show_browser {
2928                            app.set_export_folder(app.browser.current_path.clone());
2929                        }
2930                    }
2931                    'f' => {
2932                        if app.show_browser {
2933                            // `f` toggles between the normal folder view and
2934                            // a flat list of favourite folders. The bar at
2935                            // the top of the browser (when visible) is still
2936                            // mouse-only; this gives a keyboard-first path
2937                            // through the favourites and also fixes the
2938                            // `..` occlusion bug because the favourites are
2939                            // rendered through the normal list widget.
2940                            if app.browsing_favourites {
2941                                app.browsing_favourites = false;
2942                                app.status_message = "Folder view".to_string();
2943                            } else if app.favourite_folders.is_empty() {
2944                                app.status_message = "No favourites yet — press [F] to add the current folder".to_string();
2945                            } else {
2946                                app.browsing_favourites = true;
2947                                app.favourites_scroll_offset = Cell::new(0);
2948                                app.status_message = "Favourites view (press [f] or [Esc] to return)".to_string();
2949                            }
2950                        }
2951                    }
2952                    'F' => {
2953                        if app.show_browser {
2954                            app.toggle_favourite_folder(app.browser.current_path.clone());
2955                        }
2956                    }
2957                    'i' => {
2958                        if app.focus_target == FocusTarget::ExportSettings
2959                            && matches!(app.active_rate_control, RateControl::Custom(_))
2960                        {
2961                            app.is_editing_custom_rate = !app.is_editing_custom_rate;
2962                            if app.is_editing_custom_rate {
2963                                app.status_message = "Type a rate value (e.g. 20, 400M, 50000k). Press Enter to confirm, Esc to cancel.".to_string();
2964                            }
2965                        } else {
2966                            app.show_full_info = !app.show_full_info;
2967                            if app.show_full_info {
2968                                app.status_message = "Full file info shown (press i or Esc to close)".to_string();
2969                            }
2970                        }
2971                    }
2972                    'p' => {
2973                        if app.focus_target == FocusTarget::ExportSettings {
2974                            // Save the current export settings as a new preset.
2975                            app.begin_naming_preset();
2976                        } else {
2977                            app.cycle_profile(true);
2978                        }
2979                    }
2980                    'P' => {
2981                        // Open the preset picker (regardless of focus —
2982                        // most useful from the Export Settings panel but
2983                        // works from anywhere for power users).
2984                        app.open_preset_picker();
2985                    }
2986                    's' => {
2987                        app.status_message = "Settings (coming soon)".to_string();
2988                    }
2989                    'n' => {
2990                        if let Some(info) = app.focused_file_info().cloned().or_else(|| app.file_info.clone()) {
2991                            let output_path = "naked_dump.raw";
2992                            app.status_message = "Starting naked raw dump...".to_string();
2993                            match crate::pipeline::run_naked(&info, output_path) {
2994                                Ok(_) => {
2995                                    app.status_message = format!("Naked dump done: {}", output_path);
2996                                }
2997                                Err(e) => {
2998                                    app.status_message = format!("Naked dump failed: {}", e);
2999                                }
3000                            }
3001                        }
3002                    }
3003                    '.' => {
3004                        if app.show_browser {
3005                            app.browser.toggle_hidden();
3006                            app.status_message = if app.browser.show_hidden {
3007                                "Showing hidden files"
3008                            } else {
3009                                "Hiding hidden files"
3010                            }.to_string();
3011                        }
3012                    }
3013                    'L' => {
3014                        let folder = app.browser.current_path.clone();
3015                        app.load_all_in_folder(&folder);
3016                        app.show_browser = false;
3017                    }
3018                    'I' => {
3019                        if app.show_browser {
3020                            app.import_selected_from_browser();
3021                        }
3022                    }
3023                    'C' => {
3024                        if !app.imported_files.is_empty() {
3025                            app.show_culling = !app.show_culling;
3026                            app.status_message = if app.show_culling { "Culling mode" } else { "Normal mode" }.to_string();
3027                        }
3028                    }
3029                    'G' => {
3030                        app.show_grade_screen = !app.show_grade_screen;
3031                        if app.show_grade_screen {
3032                            app.set_focus(FocusTarget::Grade);
3033                            app.status_message = "Grade screen — Esc to exit".to_string();
3034                        } else {
3035                            app.grade_dragging = None;
3036                            app.set_focus(FocusTarget::Preview);
3037                            app.status_message = "Normal view".to_string();
3038                        }
3039                    }
3040                    _ => {}
3041                }
3042            }
3043
3044            // ----------------------------------------------------------------
3045            // Non-character keys
3046            // ----------------------------------------------------------------
3047            match key_event.code {
3048                crossterm::event::KeyCode::Esc => {
3049                    if app.import_popup != ImportPopupState::Hidden {
3050                        app.import_popup = ImportPopupState::Hidden;
3051                    } else if app.show_full_info {
3052                        app.show_full_info = false;
3053                    } else if app.browsing_favourites {
3054                        app.browsing_favourites = false;
3055                        app.status_message = "Folder view".to_string();
3056                    } else if app.show_browser {
3057                        app.show_browser = false;
3058                    } else if app.show_grade_screen {
3059                        app.show_grade_screen = false;
3060                        app.grade_dragging = None;
3061                        app.set_focus(FocusTarget::Preview);
3062                        app.status_message = "Normal view".to_string();
3063                    } else if app.show_help {
3064                        app.show_help = false;
3065                    } else {
3066                        app.running = false;
3067                    }
3068                }
3069                crossterm::event::KeyCode::Delete => {
3070                    if app.browsing_favourites {
3071                        app.delete_selected_favourite();
3072                    }
3073                }
3074                crossterm::event::KeyCode::Tab => {
3075                    app.cycle_focus();
3076                }
3077                crossterm::event::KeyCode::Enter => {
3078                    if app.focus_target == FocusTarget::ExportSettings
3079                        && matches!(app.active_rate_control, RateControl::Custom(_))
3080                    {
3081                        app.is_editing_custom_rate = !app.is_editing_custom_rate;
3082                        if app.is_editing_custom_rate {
3083                            app.status_message = "Type a rate value. Enter to confirm, Esc to cancel.".to_string();
3084                        }
3085                    } else if app.browsing_favourites {
3086                        app.open_selected_favourite();
3087                    } else if app.show_browser {
3088                        app.navigate_browser(BrowserDirection::Enter);
3089                    }
3090                }
3091                crossterm::event::KeyCode::Right | crossterm::event::KeyCode::Char('l') => {
3092                    if app.focus_target == FocusTarget::Grade {
3093                        let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3094                            GradeSliders::step_large(app.grade_focus)
3095                        } else {
3096                            GradeSliders::step_small(app.grade_focus)
3097                        };
3098                        let old_norm = app.grade_sliders.normalized(app.grade_focus);
3099                        app.grade_sliders.apply_delta(app.grade_focus, step);
3100                        app.phosphor_trail.push((old_norm, 4));
3101                        app.grade_strip_active = true;
3102                        app.grade_strip_idle_ticks = 15;
3103                    } else if app.frame_index < app.frame_count.saturating_sub(1) {
3104                        app.frame_index += 1;
3105                    }
3106                }
3107                crossterm::event::KeyCode::Left | crossterm::event::KeyCode::Char('h') => {
3108                    if app.focus_target == FocusTarget::Grade {
3109                        let step = if key_event.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
3110                            GradeSliders::step_large(app.grade_focus)
3111                        } else {
3112                            GradeSliders::step_small(app.grade_focus)
3113                        };
3114                        let old_norm = app.grade_sliders.normalized(app.grade_focus);
3115                        app.grade_sliders.apply_delta(app.grade_focus, -step);
3116                        app.phosphor_trail.push((old_norm, 4));
3117                        app.grade_strip_active = true;
3118                        app.grade_strip_idle_ticks = 15;
3119                    } else if app.frame_index > 0 {
3120                        app.frame_index -= 1;
3121                    }
3122                }
3123                crossterm::event::KeyCode::Char('L') => {
3124                    if app.focus_target == FocusTarget::Grade {
3125                        let step = GradeSliders::step_large(app.grade_focus);
3126                        let old_norm = app.grade_sliders.normalized(app.grade_focus);
3127                        app.grade_sliders.apply_delta(app.grade_focus, step);
3128                        app.phosphor_trail.push((old_norm, 4));
3129                        app.grade_strip_active = true;
3130                        app.grade_strip_idle_ticks = 15;
3131                    } else {
3132                        let jump = 10.min(app.frame_count.saturating_sub(app.frame_index + 1));
3133                        app.frame_index = app.frame_index.saturating_add(jump);
3134                    }
3135                }
3136                crossterm::event::KeyCode::Char('H') => {
3137                    if app.focus_target == FocusTarget::Grade {
3138                        let step = GradeSliders::step_large(app.grade_focus);
3139                        let old_norm = app.grade_sliders.normalized(app.grade_focus);
3140                        app.grade_sliders.apply_delta(app.grade_focus, -step);
3141                        app.phosphor_trail.push((old_norm, 4));
3142                        app.grade_strip_active = true;
3143                        app.grade_strip_idle_ticks = 15;
3144                    } else {
3145                        app.frame_index = app.frame_index.saturating_sub(10);
3146                    }
3147                }
3148                crossterm::event::KeyCode::Up | crossterm::event::KeyCode::Char('k') => {
3149                    if app.show_help {
3150                        app.help_scroll = app.help_scroll.saturating_sub(1);
3151                    } else if app.browsing_favourites {
3152                        app.navigate_favourites(-1);
3153                    } else if app.show_browser {
3154                        app.navigate_browser(BrowserDirection::Up);
3155                    } else {
3156                        match app.focus_target {
3157                            FocusTarget::MediaPool => {
3158                                if app.media_pool_index > 0 {
3159                                    app.media_pool_index -= 1;
3160                                }
3161                            }
3162                            FocusTarget::Queue => {
3163                                if app.queue_index > 0 {
3164                                    app.queue_index -= 1;
3165                                }
3166                            }
3167                            FocusTarget::ExportSettings => {
3168                                app.export_focus = match app.export_focus {
3169                                    ExportFocus::ColorSpace => ExportFocus::RateControl,
3170                                    ExportFocus::TransferFunction => ExportFocus::ColorSpace,
3171                                    ExportFocus::CodecFamily => ExportFocus::TransferFunction,
3172                                    ExportFocus::Profile => ExportFocus::CodecFamily,
3173                                    ExportFocus::RateControl => ExportFocus::Profile,
3174                                };
3175                            }
3176                            FocusTarget::Preview => {}
3177                            FocusTarget::Grade => {
3178                                if app.grade_focus > 0 {
3179                                    app.grade_morph = Some((app.grade_focus, 4));
3180                                    app.grade_focus -= 1;
3181                                    app.grade_strip_active = true;
3182                                    app.grade_strip_idle_ticks = 15;
3183                                }
3184                            }
3185                        }
3186                    }
3187                }
3188                crossterm::event::KeyCode::Down | crossterm::event::KeyCode::Char('j') => {
3189                    if app.show_help {
3190                        app.help_scroll = app.help_scroll.saturating_add(1);
3191                    } else if app.browsing_favourites {
3192                        app.navigate_favourites(1);
3193                    } else if app.show_browser {
3194                        app.navigate_browser(BrowserDirection::Down);
3195                    } else {
3196                        match app.focus_target {
3197                            FocusTarget::MediaPool => {
3198                                if app.media_pool_index + 1 < app.imported_files.len() {
3199                                    app.media_pool_index += 1;
3200                                }
3201                            }
3202                            FocusTarget::Queue => {
3203                                if app.queue_index + 1 < app.queue.len() {
3204                                    app.queue_index += 1;
3205                                }
3206                            }
3207                            FocusTarget::ExportSettings => {
3208                                app.export_focus = match app.export_focus {
3209                                    ExportFocus::ColorSpace => ExportFocus::TransferFunction,
3210                                    ExportFocus::TransferFunction => ExportFocus::CodecFamily,
3211                                    ExportFocus::CodecFamily => ExportFocus::Profile,
3212                                    ExportFocus::Profile => ExportFocus::RateControl,
3213                                    ExportFocus::RateControl => ExportFocus::ColorSpace,
3214                                };
3215                            }
3216                            FocusTarget::Preview => {}
3217                            FocusTarget::Grade => {
3218                                if app.grade_focus + 1 < GradeSliders::count() {
3219                                    app.grade_morph = Some((app.grade_focus, 4));
3220                                    app.grade_focus += 1;
3221                                    app.grade_strip_active = true;
3222                                    app.grade_strip_idle_ticks = 15;
3223                                }
3224                            }
3225                        }
3226                    }
3227                }
3228                crossterm::event::KeyCode::Char(' ') => {
3229                    if app.show_browser {
3230                        app.browser.toggle_selection();
3231                    } else {
3232                        match app.focus_target {
3233                            FocusTarget::MediaPool => app.toggle_media_pool_selection(),
3234                            FocusTarget::Queue => app.toggle_queue_selection(),
3235                            FocusTarget::ExportSettings => {}
3236                            FocusTarget::Preview => {}
3237                            FocusTarget::Grade => {}
3238                        }
3239                    }
3240                }
3241                crossterm::event::KeyCode::PageUp => {
3242                    if app.show_help {
3243                        app.help_scroll = app.help_scroll.saturating_sub(10);
3244                    } else if app.browsing_favourites {
3245                        app.navigate_favourites(-10);
3246                    } else if app.show_browser {
3247                        let entries_len = app.browser.entries.len();
3248                        if entries_len > 0 {
3249                            let new_index = app.browser.selected_index.saturating_sub(10.min(entries_len));
3250                            app.browser.selected_index = new_index;
3251                        }
3252                    } else if app.focus_target == FocusTarget::MediaPool {
3253                        let len = app.imported_files.len();
3254                        if len > 0 {
3255                            app.media_pool_index = app.media_pool_index.saturating_sub(10.min(len));
3256                        }
3257                    } else if app.focus_target == FocusTarget::Queue {
3258                        let len = app.queue.len();
3259                        if len > 0 {
3260                            app.queue_index = app.queue_index.saturating_sub(10.min(len));
3261                        }
3262                    }
3263                }
3264                crossterm::event::KeyCode::PageDown => {
3265                    if app.show_help {
3266                        app.help_scroll = app.help_scroll.saturating_add(10);
3267                    } else if app.browsing_favourites {
3268                        app.navigate_favourites(10);
3269                    } else if app.show_browser {
3270                        let entries_len = app.browser.entries.len();
3271                        if entries_len > 0 {
3272                            let new_index = (app.browser.selected_index + 10).min(entries_len - 1);
3273                            app.browser.selected_index = new_index;
3274                        }
3275                    } else if app.focus_target == FocusTarget::MediaPool {
3276                        let len = app.imported_files.len();
3277                        if len > 0 {
3278                            app.media_pool_index = (app.media_pool_index + 10).min(len - 1);
3279                        }
3280                    } else if app.focus_target == FocusTarget::Queue {
3281                        let len = app.queue.len();
3282                        if len > 0 {
3283                            app.queue_index = (app.queue_index + 10).min(len - 1);
3284                        }
3285                    }
3286                }
3287                crossterm::event::KeyCode::Home => {
3288                    if app.browsing_favourites {
3289                        app.favourites_scroll_offset = Cell::new(0);
3290                    } else if app.show_browser {
3291                        app.browser.selected_index = 0;
3292                    } else if app.focus_target == FocusTarget::MediaPool {
3293                        app.media_pool_index = 0;
3294                    } else if app.focus_target == FocusTarget::Queue {
3295                        app.queue_index = 0;
3296                    } else {
3297                        app.frame_index = 0;
3298                    }
3299                }
3300                crossterm::event::KeyCode::End => {
3301                    if app.browsing_favourites {
3302                        if !app.favourite_folders.is_empty() {
3303                            app.favourites_scroll_offset
3304                                .set(app.favourite_folders.len() - 1);
3305                        }
3306                    } else if app.show_browser {
3307                        let entries_len = app.browser.entries.len();
3308                        if entries_len > 0 {
3309                            app.browser.selected_index = entries_len - 1;
3310                        }
3311                    } else if app.focus_target == FocusTarget::MediaPool {
3312                        if !app.imported_files.is_empty() {
3313                            app.media_pool_index = app.imported_files.len() - 1;
3314                        }
3315                    } else if app.focus_target == FocusTarget::Queue {
3316                        if !app.queue.is_empty() {
3317                            app.queue_index = app.queue.len() - 1;
3318                        }
3319                    } else {
3320                        app.frame_index = app.frame_count.saturating_sub(1);
3321                    }
3322                }
3323                crossterm::event::KeyCode::Backspace => {
3324                    if app.browsing_favourites {
3325                        app.browsing_favourites = false;
3326                        app.status_message = "Folder view".to_string();
3327                    } else if app.show_browser {
3328                        app.navigate_browser(BrowserDirection::GoUp);
3329                    }
3330                }
3331                _ => {}
3332            }
3333        }
3334        _ => {}
3335    }
3336}