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