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