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