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
39pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41use crate::ui::{self, ClickAction};
42
43#[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#[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#[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 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 pub last_export_summary: Option<ExportSummary>,
350
351 pub pending_export_summary: Option<ExportSummary>,
355
356 pub current_rendering_index: Option<usize>,
358
359 pub export_folder: Option<std::path::PathBuf>,
361
362 pub favourite_folders: Vec<std::path::PathBuf>,
364
365 pub help_scroll: u16,
367
368 pub show_culling: bool,
370
371 pub show_grade_screen: bool,
373
374 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 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 pub hardware_caps: crate::hardware::HardwareCaps,
394
395 pub active_rate_control: RateControl,
397 pub is_editing_custom_rate: bool,
398
399 pub grade_sliders: GradeSliders,
401 pub grade_focus: usize,
402 pub grade_dragging: Option<(usize, u16, u16)>,
404
405 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 pub last_browser_click: Option<(Instant, usize)>,
421
422 pub last_grade_click: Option<(Instant, usize)>,
424
425 pub drop_highlight: Option<Instant>,
427
428 pub drop_import_rx: Option<mpsc::Receiver<DropImportEvent>>,
430 pub drop_import_cancel: Option<Arc<AtomicBool>>,
431
432 pub drop_preview: Option<DropPreview>,
434
435 pub browser_scroll_offset: Cell<usize>,
437
438 pub show_favourites_bar: bool,
440
441 pub browsing_favourites: bool,
445
446 pub favourites_scroll_offset: Cell<usize>,
448
449 pub last_clicked_favourite: Option<(Instant, usize)>,
451
452 pub presets: Vec<crate::preset::ExportPreset>,
458
459 pub active_preset: Option<String>,
463
464 pub preset_picker: PresetPickerState,
466
467 pub preset_naming: Option<PresetNamingState>,
470
471 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 pub thumbnail_worker: Option<ThumbnailWorkerPool>,
481 pub thumbnail_requested: Option<(PathBuf, i64)>,
484
485 pub sixel_pending: Cell<bool>,
487 pub sixel_write_pos: Cell<Option<(u16, u16)>>,
488 pub sixel_occupy_size: Cell<Option<(u16, u16, u16, u16)>>,
490 pub sixel_panel_rect: Cell<Option<(u16, u16, u16, u16)>>,
493 pub last_written_media_index: Cell<Option<usize>>,
495 pub term_cell_size: Cell<(f32, f32)>,
497 pub preview_panel_chars: Cell<Option<(u16, u16)>>,
499 pub needs_rethumbnail: Cell<bool>,
502
503 pub spinner_frame: u8,
505 pub progress_anim_offset: u8,
506
507 pub fps_counter: FPSCounter,
509
510 pub shockwave_ticks_remaining: u8,
512
513 pub grade_strip_active: bool,
515 pub grade_morph: Option<(usize, u8)>,
517 pub phosphor_trail: Vec<(f32, u8)>,
519 pub grade_before_snapshot: Option<GradeSliders>,
521 pub grade_strip_idle_ticks: u8,
523
524}
525
526#[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
541pub 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
548pub 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 pub fn new() -> Self {
704 Self::new_with_placeholder(None)
705 }
706
707 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 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 self.decoder = decoder;
750 self.timestamps = timestamps;
751
752 self.preview_state = PreviewState::Empty;
754 self.pending_preview_ts = None;
755
756 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 self.last_written_media_index.set(None);
800
801 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 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 pub fn poll_thumbnail(&mut self) {
835 if let Some(ref worker) = self.thumbnail_worker {
837 while let Ok(result) = worker.result_rx.try_recv() {
838 if let Some(cached) = result.to_cached() {
840 self.thumbnail_cache.insert(result.path.clone(), cached);
841 }
842 let is_current = self.file_path.as_ref().map_or(false, |fp| *fp == *result.path.to_string_lossy());
844 if is_current {
845 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 let ts = match self.pending_preview_ts.take() {
864 Some(ts) => ts,
865 None => return,
866 };
867
868 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 if !needs_regen && self.thumbnail_requested.as_ref() == Some(&(path_buf.clone(), ts)) {
892 return;
893 }
894
895 if self.preview_panel_chars.get().is_none() {
898 self.pending_preview_ts = Some(ts); return;
900 }
901
902 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 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 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 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 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 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 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 let as_shot = [1.0f32, 1.0, 1.0];
1003
1004 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 let exposure_stops = self.grade_sliders.exposure;
1013
1014 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 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 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 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 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 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 pub fn start_async_import(&mut self, paths: Vec<String>) {
1174 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 self.last_export_summary = None;
1764 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 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 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 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 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 app.spinner_frame = app.spinner_frame.wrapping_add(1);
2622 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 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 app.phosphor_trail.iter_mut().for_each(|(_, t)| *t = t.saturating_sub(1));
2636 app.phosphor_trail.retain(|(_, t)| *t > 0);
2637 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 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 let current_idx = app.media_pool_index;
2669 let file_changed = app.last_written_media_index.get() != Some(current_idx);
2670
2671 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 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 (pw, (pw as f32 * cell_aspect / img_aspect_px).round().max(1.0) as u16)
2710 } else {
2711 ((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 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
2770fn 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
2787fn 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
2802fn decode_file_uri(s: &str) -> String {
2805 if let Some(rest) = s.strip_prefix("file:///") {
2806 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 return format!("/{}", rest);
2815 }
2816 if let Some(rest) = s.strip_prefix("file://") {
2817 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
2826fn 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(), }
2835}
2836
2837fn normalize_path(s: &str) -> String {
2839 if cfg!(windows) {
2840 if s.starts_with("\\\\") {
2842 return s.to_string();
2843 }
2844 s.replace('/', "\\")
2846 } else {
2847 s.to_string()
2848 }
2849}
2850
2851fn validate_path(s: &str) -> Option<String> {
2853 let path = std::path::Path::new(s);
2854
2855 if !path.exists() {
2857 tracing::debug!("path validation: does not exist: {}", s);
2858 return None;
2859 }
2860
2861 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 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 let stripped = strip_surrounding_quotes(line);
2890
2891 let expanded = expand_tilde(&stripped);
2893
2894 let decoded = decode_file_uri(&expanded);
2896
2897 let percent_decoded = percent_decode_path(&decoded);
2899
2900 let normalized = normalize_path(&percent_decoded);
2902
2903 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 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 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 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 app.drop_highlight = Some(Instant::now());
2955
2956 const ASYNC_THRESHOLD: usize = 3;
2959
2960 if mcraw_files.len() <= ASYNC_THRESHOLD && folders.is_empty() {
2961 app.start_async_import(mcraw_files);
2963 } else {
2964 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 if all_in_folder.len() == 1 {
2988 app.start_async_import(mcraw_files);
2989 return;
2990 }
2991 }
2992
2993 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 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 app.import_popup = ImportPopupState::DroppedFiles {
3019 files: mcraw_files,
3020 folder,
3021 all_in_folder,
3022 };
3023 }
3024 }
3025
3026 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 Event::Mouse(mouse_event) => {
3040 use crossterm::event::{MouseEventKind, MouseButton};
3041
3042 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 ®ion.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 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 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 ®ion.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 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 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 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 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 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; return;
3272 }
3273
3274 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 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 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 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 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 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 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 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 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 app.begin_naming_preset();
3593 } else {
3594 app.cycle_profile(true);
3595 }
3596 }
3597 'P' => {
3598 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 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 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