Skip to main content

mcraw_tui/
app.rs

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