1use ratatui::{
2 layout::{Alignment, Constraint, Direction, Layout, Rect},
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
6 Frame,
7};
8use std::time::Duration;
9
10use crate::app::{App, ExportFocus, FocusTarget, ImportPopupState, QueueStatus};
11use crate::export::CodecFamily;
12use crate::file::McrawFileInfo;
13use crate::gradient::{multi_stop_color, GRADIENT_COOL, GRADIENT_WARM};
14
15
16struct Palette;
23impl Palette {
24 const BG_VOID: Color = Color::Rgb(0x0A, 0x0D, 0x08);
26 const BG_PANEL: Color = Color::Rgb(0x12, 0x17, 0x0F);
27 const BG_ELEVATED: Color = Color::Rgb(0x1E, 0x25, 0x18);
28 const TEXT_PRIMARY: Color = Color::Rgb(0xE8, 0xE4, 0xD9);
30 const TEXT_SECONDARY: Color = Color::Rgb(0x8A, 0x9A, 0x8E);
31 const ACCENT_AMBER: Color = Color::Rgb(0xE8, 0xA0, 0x35);
33 const ACCENT_GREEN: Color = Color::Rgb(0x45, 0xE8, 0x8A);
34 const ACCENT_EMBER: Color = Color::Rgb(0xC4, 0x5C, 0x3C);
35 const ACCENT_MIST: Color = Color::Rgb(0x6D, 0xAE, 0xAE);
36 const BORDER_DIM: Color = Color::Rgb(0x2E, 0x3A, 0x28);
38 const BORDER_FOCUS: Color = Color::Rgb(0xE8, 0xA0, 0x35);
39 const SUCCESS: Color = Color::Rgb(0x45, 0xE8, 0x8A);
41 const WARNING: Color = Color::Rgb(0xE8, 0xA0, 0x35);
42 const ERROR: Color = Color::Rgb(0xC4, 0x5C, 0x3C);
43 const QUEUE_WAITING: Color = Color::Rgb(0x8A, 0x9A, 0x8E);
45 const QUEUE_RENDERING: Color = Color::Rgb(0xE8, 0xA0, 0x35);
46 const QUEUE_COMPLETED: Color = Color::Rgb(0x45, 0xE8, 0x8A);
47 const QUEUE_FAILED: Color = Color::Rgb(0xC4, 0x5C, 0x3C);
48 const BROWSER_DIR: Color = Color::Rgb(0xE8, 0xA0, 0x35);
50 const BROWSER_MCRAW: Color = Color::Rgb(0x45, 0xE8, 0x8A);
51 const BROWSER_OTHER: Color = Color::Rgb(0x8A, 0x9A, 0x8E);
52 const HW_CODEC: Color = Color::Rgb(0x45, 0xE8, 0x8A);
54 const SW_CODEC: Color = Color::Rgb(0x8A, 0x9A, 0x8E);
55 const IMPORT_PROMPT: Color = Color::Rgb(0xE8, 0xA0, 0x35);
57 const STATUS_KEY: Color = Color::Rgb(0x6D, 0xAE, 0xAE);
58 const BORDER: Color = Self::BORDER_DIM;
60 const BORDER_FOCUSED: Color = Self::BORDER_FOCUS;
61 const LABEL: Color = Self::TEXT_SECONDARY;
62 const VALUE: Color = Self::TEXT_PRIMARY;
63 const FOCUSED: Color = Self::ACCENT_AMBER;
64 const CHECKED: Color = Self::ACCENT_GREEN;
65 const UNCHECKED: Color = Self::TEXT_SECONDARY;
66 const HIGHLIGHT_BG: Color = Self::BG_ELEVATED;
67 const HIGHLIGHT_FOCUSED_BG: Color = Color::Rgb(0x2A, 0x35, 0x22);
68 const BUTTON_BG: Color = Self::BG_ELEVATED;
69 const BUTTON_FG: Color = Self::TEXT_PRIMARY;
70 const POPUP_TITLE: Color = Self::ACCENT_AMBER;
71 const POPUP_BORDER: Color = Self::BORDER_FOCUS;
72 const PROGRESS_BAR_BG: Color = Self::BG_ELEVATED;
73 const PROGRESS_BAR_FG: Color = Self::ACCENT_GREEN;
74 const PANEL_BG: Color = Self::BG_PANEL;
75 const HEADER_BG: Color = Self::BG_VOID;
76 const HEADER_FG: Color = Self::TEXT_PRIMARY;
77}
78
79#[derive(Debug, Clone)]
84pub struct ClickRegion {
85 pub area: Rect,
86 pub action: ClickAction,
87}
88
89#[derive(Debug, Clone, PartialEq)]
90pub enum ClickAction {
91 ToggleBrowser,
92 ToggleFileSelection(usize),
93 ToggleQueueSelection(usize),
94 SelectMediaPoolItem(usize),
95 SelectQueueItem(usize),
96 FocusMediaPool,
97 FocusQueue,
98 FocusExport,
99 AddSelectedToQueue,
100 AddAllToQueue,
101 RenderSelected,
102 RenderAll,
103 ClearQueue,
104 CycleCodec,
105 CycleGamut,
106 CycleTransfer,
107 CycleProfile,
108 CycleRate,
109 ImportOption1,
110 ImportOption2,
111 ClosePopup,
112 ToggleHelp,
113 BrowserNavigate(usize),
114 BrowserSelectAndEnter(usize),
115 BrowserEnter,
116 BrowserGoUp,
117 RemoveSelectedFromMediaPool,
118 ToggleBrowserSelection(usize),
119 FavouriteNavigate(usize),
120 OpenPresetPicker,
121 GradeSlider(usize),
122 FocusGrade,
123 ToggleSelectAll,
124 CycleFps,
125}
126
127pub fn render(frame: &mut Frame, app: &App, regions: &mut Vec<ClickRegion>) {
132 let size = frame.area();
133 frame.render_widget(Clear, size);
134
135 app.sixel_pending.set(false);
140 app.sixel_write_pos.set(None);
141
142 let vert = Layout::default()
143 .direction(Direction::Vertical)
144 .constraints([
145 Constraint::Length(3),
146 Constraint::Min(10),
147 Constraint::Length(2),
148 ])
149 .split(size);
150
151 render_header(frame, vert[0], app, regions);
152
153 if app.imported_files.is_empty() && !app.show_browser {
154 app.sixel_pending.set(false);
156 app.sixel_write_pos.set(None);
157 render_empty_state(frame, vert[1], app, regions);
158 } else if app.imported_files.is_empty() {
159 app.sixel_pending.set(false);
161 app.sixel_write_pos.set(None);
162 let body_block = ratatui::widgets::Block::default()
164 .borders(Borders::ALL)
165 .border_style(Style::default().fg(Palette::BORDER));
166 frame.render_widget(body_block, vert[1]);
167 } else if app.show_culling {
168 app.sixel_pending.set(false);
169 app.sixel_write_pos.set(None);
170 render_culling_screen(frame, vert[1], app, regions);
171 } else if app.show_grade_screen {
172 app.sixel_pending.set(false);
173 app.sixel_write_pos.set(None);
174 render_grade_screen_body(frame, vert[1], app, regions);
175 } else {
176 render_body(frame, vert[1], app, regions);
177 }
178
179 render_status(frame, app, vert[2], regions);
180
181 if app.show_browser {
183 render_browser_overlay(frame, size, app, regions);
184 }
185 if app.import_popup != ImportPopupState::Hidden {
186 render_import_popup(frame, size, app, regions);
187 }
188 if app.show_full_info {
189 render_full_info_overlay(frame, size, app);
190 }
191 if app.show_help {
192 render_help_overlay(frame, app, size);
193 }
194 if app.preset_picker.open {
195 render_preset_picker(frame, size, app);
196 }
197 if app.preset_naming.is_some() {
198 render_preset_naming(frame, size, app);
199 }
200
201 if let Some(ref preview) = app.drop_preview {
203 if preview.start_time.elapsed() < Duration::from_secs(2) {
204 render_drop_preview(frame, size, preview);
205 }
206 }
207}
208
209fn render_header(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
214 let btn_total: u16 = 28; let header_layout = Layout::default()
217 .direction(Direction::Horizontal)
218 .constraints([
219 Constraint::Fill(1),
220 Constraint::Length(btn_total),
221 ])
222 .split(area);
223
224 let left = header_layout[0];
225 let right = header_layout[1];
226
227 let mut spans = vec![
229 Span::styled(" mcraw-tui ", Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)),
230 Span::raw(" "),
231 ];
232 if let Some(ref path) = app.file_path {
233 let name = path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(path);
234 spans.push(Span::styled(name, Style::default().fg(Palette::TEXT_PRIMARY).add_modifier(Modifier::BOLD)));
235 spans.push(Span::raw(" "));
236 }
237 spans.push(Span::styled(format!("{} imported", app.imported_files.len()), Style::default().fg(Palette::TEXT_SECONDARY)));
238 spans.push(Span::raw(" | "));
239 spans.push(Span::styled(format!("Queue: {}", app.queue.len()), Style::default().fg(Palette::TEXT_SECONDARY)));
240 if app.is_exporting {
241 spans.push(Span::raw(" | "));
242 spans.push(Span::styled(format!("[{:.0}%]", app.export_progress), Style::default().fg(Palette::SUCCESS).add_modifier(Modifier::BOLD)));
243 }
244
245 let fps = app.fps_counter.fps();
247 let fps_color = if fps > 55.0 {
248 Palette::ACCENT_GREEN
249 } else if fps > 30.0 {
250 Palette::ACCENT_AMBER
251 } else {
252 Palette::ACCENT_EMBER
253 };
254 let fps_int = fps as u32;
255 let fps_dec = ((fps - fps_int as f64) * 10.0) as u8;
256 spans.push(Span::raw(" "));
257 spans.push(Span::styled(
258 format!("[{}", fps_int),
259 Style::default().fg(fps_color).add_modifier(Modifier::BOLD),
260 ));
261 spans.push(Span::styled(
262 format!(".{}fps]", fps_dec),
263 Style::default().fg(Palette::TEXT_SECONDARY),
264 ));
265
266 let resolution = app.file_info.as_ref().map(|info| {
268 if info.width >= 3800 || info.height >= 2100 { "4K".to_string() }
269 else if info.width >= 2500 || info.height >= 1400 { "1440p".to_string() }
270 else if info.width >= 1900 || info.height >= 1000 { "1080p".to_string() }
271 else if info.width >= 1200 || info.height >= 700 { "720p".to_string() }
272 else { format!("{}p", info.height) }
273 });
274 if let Some(ref res) = resolution {
275 spans.push(Span::raw(" "));
276 spans.push(Span::styled(format!("[{}]", res), Style::default().fg(Palette::TEXT_SECONDARY)));
277 }
278
279 frame.render_widget(
280 Paragraph::new(Line::from(spans)).block(Block::default()),
281 left,
282 );
283
284 let is_grade_focused = app.focus_target == FocusTarget::Grade;
286 let grade_style = if is_grade_focused {
287 Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)
288 } else {
289 Style::default().fg(Palette::TEXT_SECONDARY)
290 };
291 let grade_label = if is_grade_focused { "◆ Grade" } else { "Grade" };
292 let toggle_label = if app.show_browser { "[Hide] Browser" } else { "[Show] Browser" };
293 let toggle_style = Style::default().fg(Palette::STATUS_KEY).add_modifier(Modifier::BOLD);
294
295 let right_line = Line::from(vec![
296 Span::styled(grade_label, grade_style),
297 Span::raw(" "),
298 Span::styled(toggle_label, toggle_style),
299 ]);
300 frame.render_widget(Paragraph::new(right_line), right);
301
302 let grade_btn_w: u16 = 8; let toggle_w: u16 = 18; let gap: u16 = 2; let base_x = right.x;
307 regions.push(ClickRegion {
308 area: Rect { x: base_x, y: area.y, width: grade_btn_w, height: area.height },
309 action: ClickAction::FocusGrade,
310 });
311 regions.push(ClickRegion {
312 area: Rect { x: base_x + grade_btn_w + gap, y: area.y, width: toggle_w, height: area.height },
313 action: ClickAction::ToggleBrowser,
314 });
315}
316
317fn render_empty_state(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
322 let lines = vec![
323 Line::from(""),
324 Line::from(""),
325 Line::from(Span::styled(
326 " Import .mcraw files to get started",
327 Style::default().fg(Palette::IMPORT_PROMPT).add_modifier(Modifier::BOLD),
328 )),
329 Line::from(""),
330 Line::from(Span::styled(
331 " Press [b] to toggle file browser",
332 Style::default().fg(Color::White),
333 )),
334 Line::from(""),
335 Line::from(Span::styled(
336 " [b] Toggle Browser [?] Help",
337 Style::default().fg(Palette::STATUS_KEY).add_modifier(Modifier::BOLD),
338 )),
339 ];
340
341 let panel = Paragraph::new(lines)
342 .alignment(ratatui::layout::Alignment::Center)
343 .block(
344 Block::default()
345 .title(" Welcome ")
346 .borders(Borders::ALL)
347 .border_style(Style::default().fg(Palette::BORDER)),
348 );
349 frame.render_widget(panel, area);
350}
351
352fn render_body(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
357 let vert = Layout::default()
358 .direction(Direction::Vertical)
359 .constraints([
360 Constraint::Percentage(50),
361 Constraint::Percentage(50),
362 ])
363 .split(area);
364
365 let top = Layout::default()
366 .direction(Direction::Horizontal)
367 .constraints([
368 Constraint::Percentage(35),
369 Constraint::Percentage(65),
370 ])
371 .split(vert[0]);
372
373 let preview_split = Layout::default()
374 .direction(Direction::Horizontal)
375 .constraints([
376 Constraint::Percentage(50),
377 Constraint::Percentage(50),
378 ])
379 .split(top[1]);
380 let preview_left = preview_split[0];
381 let preview_right = preview_split[1];
382
383 if app.focus_target == FocusTarget::Grade {
384 let bottom = Layout::default()
385 .direction(Direction::Horizontal)
386 .constraints([
387 Constraint::Percentage(35),
388 Constraint::Percentage(65),
389 ])
390 .split(vert[1]);
391 render_media_pool(frame, app, top[0], regions);
392 render_info_panel(frame, app, preview_left);
393 render_thumbnail_panel(frame, app, preview_right);
394 render_export_settings(frame, app, bottom[0], regions);
395 render_queue_panel(frame, app, bottom[1], regions);
396 } else {
397 let bottom = Layout::default()
399 .direction(Direction::Horizontal)
400 .constraints([
401 Constraint::Percentage(35),
402 Constraint::Percentage(65),
403 ])
404 .split(vert[1]);
405 render_media_pool(frame, app, top[0], regions);
406 render_info_panel(frame, app, preview_left);
407 render_thumbnail_panel(frame, app, preview_right);
408 render_export_settings(frame, app, bottom[0], regions);
409 render_queue_panel(frame, app, bottom[1], regions);
410 }
411}
412
413fn render_grade_screen_body(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
414 let strip_height: u16 = 3;
419 let preview_area = Rect {
420 x: area.x,
421 y: area.y,
422 width: area.width,
423 height: area.height.saturating_sub(strip_height),
424 };
425 let strip_area = Rect {
426 x: area.x,
427 y: area.y + preview_area.height,
428 width: area.width,
429 height: strip_height,
430 };
431
432 let canvas_border = if app.grade_before_snapshot.is_some() {
434 shockwave_border(app.shockwave_ticks_remaining, Palette::ACCENT_AMBER)
436 } else {
437 Palette::BG_VOID
438 };
439 frame.render_widget(
440 Block::default()
441 .borders(Borders::ALL)
442 .border_style(Style::default().fg(canvas_border))
443 .style(Style::default().bg(Palette::BG_VOID)),
444 preview_area,
445 );
446
447 let file_name = app.file_path.as_ref()
449 .map(|s| std::path::Path::new(s))
450 .and_then(|p| p.file_name())
451 .and_then(|n| n.to_str())
452 .unwrap_or("Untitled");
453 let resolution = app.file_info.as_ref()
454 .map(|info| format!("{}x{}", info.width, info.height))
455 .unwrap_or_else(|| "N/A".to_string());
456 let frame_count = app.frame_count;
457 let fps = app.file_info.as_ref()
458 .map(|info| format!("{:.1}fps", info.fps))
459 .unwrap_or_else(|| "N/A".to_string());
460
461 let preview_lines = vec![
462 Line::from(Span::styled(
463 "◆ PREVIEW",
464 Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD),
465 )),
466 Line::from(Span::styled(
467 "GPU Pipeline Coming Soon",
468 Style::default().fg(Palette::TEXT_SECONDARY),
469 )),
470 Line::from(""),
471 Line::from(Span::styled(
472 file_name,
473 Style::default().fg(Palette::TEXT_PRIMARY).add_modifier(Modifier::BOLD),
474 )),
475 Line::from(Span::styled(
476 format!("{} | {} frames | {}", resolution, frame_count, fps),
477 Style::default().fg(Palette::TEXT_SECONDARY),
478 )),
479 Line::from(""),
480 Line::from(Span::styled(
481 "↑↓ category ←→ adjust B before/after Esc exit",
482 Style::default().fg(Palette::STATUS_KEY),
483 )),
484 ];
485
486 let overlay = Paragraph::new(preview_lines)
487 .alignment(Alignment::Center)
488 .block(Block::default().borders(Borders::NONE));
489 let overlay_area = Rect {
491 x: preview_area.x,
492 y: preview_area.y + preview_area.height.saturating_sub(8) / 2,
493 width: preview_area.width,
494 height: 8,
495 };
496 frame.render_widget(overlay, overlay_area);
497
498 let strip_border = if app.grade_before_snapshot.is_some() {
500 shockwave_border(app.shockwave_ticks_remaining, Palette::BORDER_FOCUSED)
501 } else {
502 Palette::BORDER_DIM
503 };
504 let strip_line = focus_strip(app, strip_area.width.saturating_sub(4));
505 frame.render_widget(
506 Paragraph::new(strip_line)
507 .block(
508 Block::default()
509 .borders(Borders::ALL)
510 .border_style(Style::default().fg(strip_border)),
511 ),
512 strip_area,
513 );
514}
515
516fn render_culling_screen(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
521 let horiz = Layout::default()
522 .direction(Direction::Horizontal)
523 .constraints([
524 Constraint::Percentage(30),
525 Constraint::Percentage(70),
526 ])
527 .split(area);
528
529 let left_inner = horiz[0].height.saturating_sub(2) as usize;
531 let is_left_focused = app.focus_target == FocusTarget::MediaPool;
532 let left_border = if is_left_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER };
533
534 let items: Vec<ListItem> = app.imported_files.iter().enumerate().map(|(_i, f)| {
535 let name = f.path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(&f.path);
536 let checkbox = if f.selected {
537 Span::styled("◉ ", Style::default().fg(Palette::CHECKED).add_modifier(Modifier::BOLD))
538 } else {
539 Span::styled("◌ ", Style::default().fg(Palette::UNCHECKED))
540 };
541 let content = Line::from(vec![
542 checkbox,
543 Span::styled(name, Style::default().fg(Color::White)),
544 Span::raw(" "),
545 Span::styled(format!("{}x{}", f.info.width, f.info.height), Style::default().fg(Color::Cyan)),
546 ]);
547 ListItem::new(content)
548 }).collect();
549
550 let list = List::new(items)
551 .block(Block::default().title(format!(" Culling ({}) ", app.imported_files.len())).borders(Borders::ALL).border_style(Style::default().fg(left_border)))
552 .highlight_style(if is_left_focused {
553 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD).bg(Palette::HIGHLIGHT_FOCUSED_BG)
554 } else {
555 Style::default().fg(Color::White).bg(Palette::HIGHLIGHT_BG)
556 })
557 .highlight_symbol("> ");
558 let mut state = ListState::default();
559 state.select(Some(app.media_pool_index));
560 frame.render_stateful_widget(list, horiz[0], &mut state);
561
562 let right_border = Palette::BORDER;
564 if let Some(info) = app.focused_file_info().or(app.file_info.as_ref()) {
565 let name = info.path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(&info.path);
566 let text = vec![
567 Line::from(Span::styled(format!(" {}", name), Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))),
568 Line::from(""),
569 Line::from(vec![Span::styled(" Resolution: ", Style::default().fg(Palette::LABEL)), Span::styled(format!("{} x {}", info.width, info.height), Style::default().fg(Palette::VALUE))]),
570 Line::from(vec![Span::styled(" Frames: ", Style::default().fg(Palette::LABEL)), Span::styled(format!("{}", info.frame_count), Style::default().fg(Palette::VALUE))]),
571 Line::from(vec![Span::styled(" FPS: ", Style::default().fg(Palette::LABEL)), Span::styled(format!("{:.1}", info.fps), Style::default().fg(Palette::VALUE))]),
572 Line::from(vec![Span::styled(" Camera: ", Style::default().fg(Palette::LABEL)), Span::styled(info.camera_metadata.camera_model.as_deref().unwrap_or("MotionCam"), Style::default().fg(Palette::VALUE))]),
573 Line::from(""),
574 Line::from(Span::styled(" ╱|_______ ", Style::default().fg(Color::Yellow))),
575 Line::from(Span::styled(" (˶❛_❛˵) / ", Style::default().fg(Color::Yellow))),
576 Line::from(Span::styled(" ^^ ^^ ", Style::default().fg(Color::Yellow))),
577 Line::from(""),
578 Line::from(Span::styled(" Space Toggle | a Add to Queue | C Exit culling", Style::default().fg(Color::DarkGray))),
579 ];
580 let panel = Paragraph::new(text)
581 .block(Block::default().title(" Preview ").borders(Borders::ALL).border_style(Style::default().fg(right_border)))
582 .wrap(Wrap { trim: false });
583 frame.render_widget(panel, horiz[1]);
584 } else {
585 let text = vec![
586 Line::from(Span::styled(" PREVIEW", Style::default().fg(Palette::LABEL).add_modifier(Modifier::BOLD))),
587 Line::from(""),
588 Line::from(Span::styled(" No file selected", Style::default().fg(Color::DarkGray))),
589 ];
590 let panel = Paragraph::new(text)
591 .block(Block::default().title(" Preview ").borders(Borders::ALL).border_style(Style::default().fg(right_border)));
592 frame.render_widget(panel, horiz[1]);
593 }
594}
595
596fn render_browser_overlay(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
601 let browser_area = Rect {
602 x: area.x,
603 y: area.y + 3,
604 width: area.width / 3,
605 height: area.height.saturating_sub(5),
606 };
607
608 frame.render_widget(Clear, browser_area);
609
610 let inner_h = browser_area.height.saturating_sub(2);
612 let has_room_for_buttons = inner_h >= 3;
613
614 let show_fav_bar = app.show_favourites_bar
620 && !app.browsing_favourites
621 && !app.favourite_folders.is_empty();
622 let bar_rows: u16 = if show_fav_bar { 1 } else { 0 };
623 let button_rows: u16 = if has_room_for_buttons { 1 } else { 0 };
624
625 let inner_x = browser_area.x + 1;
626 let inner_w = browser_area.width.saturating_sub(2);
627 let inner_y = browser_area.y + 1;
628
629 let bar_area = Rect {
630 x: inner_x,
631 y: inner_y,
632 width: inner_w,
633 height: bar_rows,
634 };
635 let list_y = inner_y + bar_rows;
636 let list_h = inner_h.saturating_sub(bar_rows + button_rows);
637 let list_area = Rect {
638 x: inner_x,
639 y: list_y,
640 width: inner_w,
641 height: list_h,
642 };
643 let button_y = inner_y + inner_h.saturating_sub(button_rows);
644 let button_area = Rect {
645 x: inner_x + 1,
646 y: button_y,
647 width: inner_w.saturating_sub(2),
648 height: button_rows,
649 };
650
651 let path_display = app.browser.current_path_display();
653 let title = if app.browsing_favourites {
654 format!(" Favourites (Esc/f to return) ")
655 } else {
656 format!(" Browse: {} ", path_display)
657 };
658
659 if show_fav_bar {
661 let mut x = bar_area.x + 1;
662 let star_style = Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD);
663 frame.render_widget(
664 Paragraph::new(Line::from(Span::styled("◆", star_style))),
665 Rect { x: bar_area.x, y: bar_area.y, width: 1, height: 1 },
666 );
667 for (i, f) in app.favourite_folders.iter().enumerate() {
668 if x >= bar_area.x + bar_area.width.saturating_sub(3) {
669 frame.render_widget(
670 Paragraph::new(Line::from(Span::styled("…", Style::default().fg(Color::DarkGray)))),
671 Rect { x, y: bar_area.y, width: 1, height: 1 },
672 );
673 break;
674 }
675 let disp = f.file_name().map(|n| n.to_string_lossy()).unwrap_or_else(|| f.to_string_lossy());
676 let text = format!(" {} ", disp);
677 let item_style = Style::default().fg(Color::Cyan).bg(Palette::HIGHLIGHT_BG);
678 let item_area = Rect { x, y: bar_area.y, width: text.len() as u16, height: 1 };
679 frame.render_widget(Paragraph::new(Line::from(Span::styled(&text, item_style))), item_area);
680 regions.push(ClickRegion { area: item_area, action: ClickAction::FavouriteNavigate(i) });
681 x = x.saturating_add(text.len() as u16 + 1);
682 }
683 }
684
685 if app.browsing_favourites {
688 let items: Vec<ListItem> = app
689 .favourite_folders
690 .iter()
691 .enumerate()
692 .map(|(i, f)| {
693 let disp = f
694 .file_name()
695 .map(|n| n.to_string_lossy().into_owned())
696 .unwrap_or_else(|| f.to_string_lossy().into_owned());
697 let full = f.display().to_string();
698 let content = vec![
699 Span::styled("◆ ", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)),
700 Span::styled(format!("{:<24}", disp), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
701 Span::styled(full, Style::default().fg(Palette::LABEL)),
702 ];
703 let _ = i;
704 ListItem::new(Line::from(content))
705 })
706 .collect();
707
708 let list = List::new(items)
709 .block(
710 Block::default()
711 .borders(Borders::ALL)
712 .border_style(Style::default().fg(Palette::BORDER_FOCUSED))
713 .title(title),
714 )
715 .highlight_style(
716 Style::default()
717 .fg(Palette::FOCUSED)
718 .add_modifier(Modifier::BOLD)
719 .bg(Palette::HIGHLIGHT_BG),
720 )
721 .highlight_symbol("> ");
722
723 let mut state = ListState::default()
724 .with_offset(app.favourites_scroll_offset.get());
725 state.select(Some(app.favourites_scroll_offset.get()));
726 frame.render_stateful_widget(list, list_area, &mut state);
727 if let Some(off) = state.offset().into() {
729 app.favourites_scroll_offset.set(off);
730 }
731 let visible_rows = list_area.height.saturating_sub(2) as usize;
733 let visible_start = app.favourites_scroll_offset.get();
734 for i in 0..visible_rows {
735 let idx = visible_start + i;
736 if idx >= app.favourite_folders.len() {
737 break;
738 }
739 let row_area = Rect {
740 x: list_area.x + 1,
741 y: list_area.y + 1 + i as u16,
742 width: list_area.width.saturating_sub(2),
743 height: 1,
744 };
745 regions.push(ClickRegion {
746 area: row_area,
747 action: ClickAction::FavouriteNavigate(idx),
748 });
749 }
750 } else {
751 let items: Vec<ListItem> = app
752 .browser
753 .entries
754 .iter()
755 .enumerate()
756 .map(|(_i, entry)| {
757 let is_mcraw = entry.name.to_lowercase().ends_with(".mcraw");
758 let checkbox = if is_mcraw {
759 if entry.selected {
760 Span::styled("◉ ", Style::default().fg(Palette::CHECKED).add_modifier(Modifier::BOLD))
761 } else {
762 Span::styled("◌ ", Style::default().fg(Palette::UNCHECKED))
763 }
764 } else {
765 Span::styled(" ", Style::default())
766 };
767 let name_style = if entry.is_dir {
768 Style::default().fg(Palette::BROWSER_DIR)
769 } else if is_mcraw {
770 Style::default().fg(Palette::BROWSER_MCRAW)
771 } else {
772 Style::default().fg(Palette::BROWSER_OTHER)
773 };
774 let mut content = vec![
775 checkbox,
776 Span::styled(&entry.name, name_style),
777 ];
778 if let Some(ref info) = entry.file_info {
779 content.push(Span::raw(" "));
780 content.push(Span::styled(
781 format!("{}x{}", info.width, info.height),
782 Style::default().fg(Palette::SUCCESS),
783 ));
784 }
785 ListItem::new(Line::from(content))
786 })
787 .collect();
788
789 let list = List::new(items)
790 .block(
791 Block::default()
792 .borders(Borders::ALL)
793 .border_style(Style::default().fg(Palette::BORDER_FOCUSED))
794 .title(title),
795 )
796 .highlight_style(
797 Style::default()
798 .fg(Palette::FOCUSED)
799 .add_modifier(Modifier::BOLD)
800 .bg(Palette::HIGHLIGHT_BG),
801 )
802 .highlight_symbol("> ");
803
804 let mut state = ListState::default()
805 .with_offset(app.browser_scroll_offset.get());
806 state.select(Some(app.browser.selected_index));
807 frame.render_stateful_widget(list, list_area, &mut state);
808 app.browser_scroll_offset.set(state.offset());
809 }
810
811 if has_room_for_buttons {
813 let import_btn = Rect { x: button_area.x, y: button_area.y, width: 16, height: 1 };
814 regions.push(ClickRegion { area: import_btn, action: ClickAction::ImportOption1 });
815 let all_btn = Rect { x: button_area.x + 17, y: button_area.y, width: 10, height: 1 };
816 regions.push(ClickRegion { area: all_btn, action: ClickAction::ImportOption2 });
817 frame.render_widget(
818 Paragraph::new(Line::from(vec![
819 Span::styled(" [I] Import Sel ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
820 Span::raw(" "),
821 Span::styled(" [L] All ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
822 ])),
823 button_area,
824 );
825 }
826
827 if !app.browsing_favourites {
830 let visible_rows = list_area.height.saturating_sub(2) as usize;
834 let visible_start = app.browser_scroll_offset.get();
835 for i in 0..visible_rows {
836 let entry_index = visible_start + i;
837 if entry_index >= app.browser.entries.len() {
838 break;
839 }
840 let is_mcraw = app.browser.entries[entry_index]
841 .name
842 .to_lowercase()
843 .ends_with(".mcraw");
844
845 if is_mcraw {
846 let cb_area = Rect {
847 x: list_area.x + 1,
848 y: list_area.y + 1 + i as u16,
849 width: 4,
850 height: 1,
851 };
852 regions.push(ClickRegion {
853 area: cb_area,
854 action: ClickAction::ToggleBrowserSelection(entry_index),
855 });
856 }
857
858 let row_area = Rect {
859 x: list_area.x + 5,
860 y: list_area.y + 1 + i as u16,
861 width: list_area.width.saturating_sub(6),
862 height: 1,
863 };
864 let action = if is_mcraw {
865 ClickAction::BrowserSelectAndEnter(entry_index)
866 } else {
867 ClickAction::BrowserNavigate(entry_index)
868 };
869 regions.push(ClickRegion { area: row_area, action });
870 }
871 }
872}
873
874fn render_media_pool(frame: &mut Frame, app: &App, area: Rect, regions: &mut Vec<ClickRegion>) {
879 let is_focused = app.focus_target == FocusTarget::MediaPool;
880 let border_color = shockwave_border(app.shockwave_ticks_remaining, if is_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER });
881 let inner_h = area.height.saturating_sub(2) as usize;
882
883 regions.push(ClickRegion { area, action: ClickAction::FocusMediaPool });
885
886 let items: Vec<ListItem> = app.imported_files.iter().enumerate().map(|(_i, f)| {
887 let name = f.path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(&f.path);
888 let checkbox = if f.selected {
889 Span::styled("◉ ", Style::default().fg(Palette::CHECKED).add_modifier(Modifier::BOLD))
890 } else {
891 Span::styled("◌ ", Style::default().fg(Palette::UNCHECKED))
892 };
893 let res = format!("{}x{}", f.info.width, f.info.height);
894 let fps = format!("{:.0}fps", f.info.fps);
895 let frames = format!("{}frm", f.info.frame_count);
896 let content = Line::from(vec![
897 checkbox,
898 Span::styled(name, Style::default().fg(Color::White)),
899 Span::raw(" "),
900 Span::styled(res, Style::default().fg(Color::Cyan)),
901 Span::raw(" "),
902 Span::styled(fps, Style::default().fg(Palette::SUCCESS)),
903 Span::raw(" "),
904 Span::styled(frames, Style::default().fg(Color::Gray)),
905 ]);
906 ListItem::new(content)
907 }).collect();
908
909 if items.is_empty() {
910 let placeholder = Paragraph::new(vec![
911 Line::from(""),
912 Line::from(Span::styled(" No files imported", Style::default().fg(Color::DarkGray))),
913 ]).block(
914 Block::default()
915 .title(" Media Pool ")
916 .borders(Borders::ALL)
917 .border_style(Style::default().fg(border_color)),
918 );
919 frame.render_widget(placeholder, area);
920 } else {
921 let has_room_for_buttons = inner_h >= 3;
923 let visible_items = if has_room_for_buttons { inner_h - 1 } else { inner_h };
924
925 let list = List::new(items)
926 .block(
927 Block::default()
928 .title(format!(" Media Pool ({}) ", app.imported_files.len()))
929 .borders(Borders::ALL)
930 .border_style(Style::default().fg(border_color)),
931 )
932 .highlight_style(
933 if is_focused {
934 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD).bg(Palette::HIGHLIGHT_FOCUSED_BG)
935 } else {
936 Style::default().fg(Color::White).bg(Palette::HIGHLIGHT_BG)
937 },
938 )
939 .highlight_symbol("> ");
940
941 let mut state = ListState::default();
942 state.select(Some(app.media_pool_index));
943 frame.render_stateful_widget(list, area, &mut state);
944
945 if has_room_for_buttons {
947 let btn_y = area.y + area.height.saturating_sub(2);
948 let btn_row = Rect {
949 x: area.x + 2,
950 y: btn_y,
951 width: area.width.saturating_sub(4),
952 height: 1,
953 };
954
955 let add_btn = Rect { x: btn_row.x, y: btn_row.y, width: 12, height: 1 };
956 regions.push(ClickRegion { area: add_btn, action: ClickAction::AddSelectedToQueue });
957
958 let add_all_btn = Rect { x: btn_row.x + 13, y: btn_row.y, width: 10, height: 1 };
959 regions.push(ClickRegion { area: add_all_btn, action: ClickAction::AddAllToQueue });
960
961 let sel_btn = Rect { x: btn_row.x + 24, y: btn_row.y, width: 10, height: 1 };
962 regions.push(ClickRegion { area: sel_btn, action: ClickAction::ToggleSelectAll });
963
964 let del_btn = Rect { x: btn_row.x + 35, y: btn_row.y, width: 10, height: 1 };
965 regions.push(ClickRegion { area: del_btn, action: ClickAction::RemoveSelectedFromMediaPool });
966
967 let all_selected = app.imported_files.iter().all(|f| f.selected);
968 let sel_label = if all_selected { "None" } else { "All" };
969
970 frame.render_widget(
971 Paragraph::new(Line::from(vec![
972 Span::styled(" [a] Add ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
973 Span::raw(" "),
974 Span::styled(" [A] All ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
975 Span::raw(" "),
976 Span::styled(format!(" [s] {} ", sel_label), Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
977 Span::raw(" "),
978 Span::styled(" [D] Del ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
979 ])),
980 btn_row,
981 );
982 }
983
984 let visible_start = if app.media_pool_index >= visible_items {
986 app.media_pool_index - visible_items + 1
987 } else {
988 0
989 };
990
991 for i in 0..visible_items.min(app.imported_files.len()) {
992 let entry_index = visible_start + i;
993 if entry_index >= app.imported_files.len() {
994 break;
995 }
996 let row_y = area.y + 1 + i as u16;
997 let cb_area = Rect { x: area.x + 2, y: row_y, width: 4, height: 1 };
998 regions.push(ClickRegion { area: cb_area, action: ClickAction::ToggleFileSelection(entry_index) });
999 let row_area = Rect { x: area.x + 6, y: row_y, width: area.width.saturating_sub(8), height: 1 };
1000 regions.push(ClickRegion { area: row_area, action: ClickAction::SelectMediaPoolItem(entry_index) });
1001 }
1002 }
1003}
1004
1005fn render_info_panel(frame: &mut Frame, app: &App, area: Rect) {
1012 let is_focused = app.focus_target == FocusTarget::Grade;
1013 let base_color = if is_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER };
1014 let border_color = shockwave_border(app.shockwave_ticks_remaining, base_color);
1015
1016 if app.is_exporting {
1017 render_render_progress(frame, app, area, border_color);
1018 } else if app.last_export_summary.is_some() {
1019 render_export_summary(frame, app, area, border_color);
1020 } else if app.focused_file_info().or(app.file_info.as_ref()).is_some() {
1021 render_file_info_panel(frame, app, area, border_color);
1022 } else {
1023 render_file_info_panel(frame, app, area, border_color);
1024 }
1025}
1026
1027fn render_thumbnail_panel(frame: &mut Frame, app: &App, area: Rect) {
1030 let is_focused = app.focus_target == FocusTarget::Grade;
1031 let base_color = if is_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER };
1032 let border_color = shockwave_border(app.shockwave_ticks_remaining, base_color);
1033 render_preview_panel(frame, app, area, border_color);
1034}
1035
1036fn render_export_summary(frame: &mut Frame, app: &App, area: Rect, border_color: Color) {
1040 let summary = match app.last_export_summary.as_ref() {
1041 Some(s) => s,
1042 None => return,
1043 };
1044
1045 let elapsed_secs = summary.elapsed.as_secs();
1046 let mins = elapsed_secs / 60;
1047 let secs = elapsed_secs % 60;
1048 let elapsed_str = if mins > 0 {
1049 format!("{}m {:02}s", mins, secs)
1050 } else {
1051 format!("{}.{:01}s", elapsed_secs, summary.elapsed.subsec_millis() / 100)
1052 };
1053
1054 let avg_fps = if summary.elapsed.as_secs_f64() > 0.0 && summary.frame_count > 0 {
1055 summary.frame_count as f64 / summary.elapsed.as_secs_f64()
1056 } else {
1057 0.0
1058 };
1059
1060 let out_name = summary
1061 .output_path
1062 .split(std::path::MAIN_SEPARATOR)
1063 .last()
1064 .unwrap_or(&summary.output_path);
1065
1066 let (status_label, status_color) = match &summary.result {
1067 Ok(()) => (" RENDER COMPLETE", Palette::SUCCESS),
1068 Err(msg) if msg == "Cancelled by user" => (" RENDER CANCELLED", Color::Yellow),
1069 Err(_) => (" RENDER FAILED", Color::Red),
1070 };
1071
1072 let mut lines = vec![
1073 Line::from(Span::styled(
1074 status_label,
1075 Style::default().fg(status_color).add_modifier(Modifier::BOLD),
1076 )),
1077 Line::from(""),
1078 Line::from(vec![
1079 Span::styled(" Output: ", Style::default().fg(Palette::LABEL)),
1080 Span::styled(out_name, Style::default().fg(Palette::VALUE)),
1081 ]),
1082 Line::from(vec![
1083 Span::styled(" Codec: ", Style::default().fg(Palette::LABEL)),
1084 Span::styled(
1085 format!("{} ({})", summary.codec_label, summary.profile_label),
1086 Style::default().fg(Palette::VALUE),
1087 ),
1088 ]),
1089 Line::from(vec![
1090 Span::styled(" Gamut: ", Style::default().fg(Palette::LABEL)),
1091 Span::styled(&summary.color_space, Style::default().fg(Palette::VALUE)),
1092 ]),
1093 Line::from(vec![
1094 Span::styled(" Transfer: ", Style::default().fg(Palette::LABEL)),
1095 Span::styled(&summary.transfer, Style::default().fg(Palette::VALUE)),
1096 ]),
1097 Line::from(vec![
1098 Span::styled(" Rate: ", Style::default().fg(Palette::LABEL)),
1099 Span::styled(&summary.rate_control, Style::default().fg(Palette::VALUE)),
1100 ]),
1101 Line::from(vec![
1102 Span::styled(" Frames: ", Style::default().fg(Palette::LABEL)),
1103 Span::styled(format!("{}", summary.frame_count), Style::default().fg(Palette::VALUE)),
1104 ]),
1105 Line::from(vec![
1106 Span::styled(" Time: ", Style::default().fg(Palette::LABEL)),
1107 Span::styled(elapsed_str, Style::default().fg(Palette::VALUE)),
1108 Span::raw(" "),
1109 Span::styled(
1110 format!("({:.1} fps avg)", avg_fps),
1111 Style::default().fg(Color::DarkGray),
1112 ),
1113 ]),
1114 ];
1115
1116 if let Err(ref msg) = summary.result {
1118 if msg != "Cancelled by user" {
1119 lines.push(Line::from(""));
1120 lines.push(Line::from(Span::styled(
1121 " Error:",
1122 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1123 )));
1124 for chunk in msg.lines().take(6) {
1126 lines.push(Line::from(Span::styled(
1127 format!(" {}", chunk),
1128 Style::default().fg(Color::Red),
1129 )));
1130 }
1131 }
1132 }
1133
1134 lines.push(Line::from(""));
1135 lines.push(Line::from(Span::styled(
1136 " Press [v] or [R] to start a new export",
1137 Style::default().fg(Color::DarkGray),
1138 )));
1139
1140 let panel = Paragraph::new(lines)
1141 .block(
1142 Block::default()
1143 .title(" Render Summary ")
1144 .borders(Borders::ALL)
1145 .border_style(Style::default().fg(border_color)),
1146 )
1147 .wrap(Wrap { trim: false });
1148 frame.render_widget(panel, area);
1149}
1150
1151fn render_file_info_panel(frame: &mut Frame, app: &App, area: Rect, border_color: Color) {
1154 app.sixel_pending.set(false);
1155 app.sixel_write_pos.set(None);
1156
1157 let label_style = Style::default().fg(Palette::LABEL);
1158 let value_style = Style::default().fg(Palette::VALUE);
1159 let info = app.focused_file_info().or(app.file_info.as_ref());
1160 let lines = info_panel_lines(info, label_style, value_style, app, area.width);
1161 let panel = Paragraph::new(lines)
1162 .block(Block::default()
1163 .title(" Info ")
1164 .borders(Borders::ALL)
1165 .border_style(Style::default().fg(border_color)))
1166 .wrap(Wrap { trim: false });
1167 frame.render_widget(panel, area);
1168}
1169
1170fn render_preview_panel(frame: &mut Frame, app: &App, area: Rect, border_color: Color) {
1172 let inner = Rect {
1173 x: area.x + 1,
1174 y: area.y + 1,
1175 width: area.width.saturating_sub(2),
1176 height: area.height.saturating_sub(2),
1177 };
1178
1179 let prev = app.preview_panel_chars.get();
1183 let curr = (inner.width, inner.height);
1184 if prev != Some(curr) {
1185 app.needs_rethumbnail.set(true);
1186 }
1187 app.preview_panel_chars.set(Some(curr));
1188
1189 match &app.preview_state {
1190 crate::preview::PreviewState::Empty => {
1191 app.sixel_pending.set(false);
1192 app.sixel_write_pos.set(None);
1193 frame.render_widget(Clear, inner);
1194
1195 let placeholder = Paragraph::new(Line::from(vec![
1196 Span::styled("Thumbnail", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD)),
1197 Span::raw(" "),
1198 Span::styled("— no preview —", Style::default().fg(Color::DarkGray)),
1199 ]))
1200 .block(Block::default()
1201 .title(" Preview ")
1202 .borders(Borders::ALL)
1203 .border_style(Style::default().fg(border_color)))
1204 .wrap(Wrap { trim: false });
1205 frame.render_widget(placeholder, area);
1206 }
1207
1208 crate::preview::PreviewState::Loading { .. } => {
1209 app.sixel_pending.set(false);
1210 app.sixel_write_pos.set(None);
1211 frame.render_widget(Clear, inner);
1212
1213 let panel = Paragraph::new(Line::from(vec![
1214 Span::styled("Preview", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD)),
1215 Span::raw(" "),
1216 Span::styled("Loading thumbnail...", Style::default().fg(Palette::TEXT_SECONDARY)),
1217 ]))
1218 .block(Block::default()
1219 .title(" Preview ")
1220 .borders(Borders::ALL)
1221 .border_style(Style::default().fg(border_color)))
1222 .wrap(Wrap { trim: false });
1223 frame.render_widget(panel, area);
1224 }
1225
1226 crate::preview::PreviewState::Ready { width, height, .. } => {
1227 frame.render_widget(Clear, inner);
1228
1229 app.sixel_panel_rect.set(Some((inner.x, inner.y, inner.width, inner.height)));
1232 app.sixel_occupy_size.set(Some((inner.x, inner.y, inner.width, inner.height)));
1233
1234 let (cell_w, cell_h) = app.term_cell_size.get();
1236 let sixel_chars_w = *width as f32 / cell_w;
1237 let sixel_chars_h = *height as f32 / cell_h;
1238
1239 let offset_x = ((inner.width as f32 - sixel_chars_w) / 2.0).max(0.0).round();
1241 let offset_y = ((inner.height as f32 - sixel_chars_h) / 2.0).max(0.0).round();
1242
1243 let sixel_x = (inner.x as i32 + offset_x as i32).max(0) as u16;
1244 let sixel_y = (inner.y as i32 + offset_y as i32).max(0) as u16;
1245
1246 app.sixel_write_pos.set(Some((sixel_x, sixel_y)));
1248 app.sixel_pending.set(true);
1249
1250 let label_panel = Paragraph::new(Line::from(vec![Span::styled(
1251 " Preview ",
1252 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
1253 )]))
1254 .block(Block::default()
1255 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
1256 .border_style(Style::default().fg(border_color)));
1257 frame.render_widget(label_panel, Rect {
1258 x: inner.x,
1259 y: inner.y.saturating_sub(1),
1260 width: inner.width,
1261 height: 1,
1262 });
1263 }
1264
1265 crate::preview::PreviewState::Error(ref msg) => {
1266 app.sixel_pending.set(false);
1267 app.sixel_write_pos.set(None);
1268 frame.render_widget(Clear, inner);
1269
1270 let lines = vec![
1271 Line::from(vec![Span::styled(" Preview", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))]),
1272 Line::from(""),
1273 Line::from(vec![
1274 Span::styled(" ⚠ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
1275 Span::styled(msg.as_str(), Style::default().fg(Color::Red)),
1276 ]),
1277 ];
1278 let panel = Paragraph::new(lines)
1279 .block(Block::default()
1280 .title(" Preview ")
1281 .borders(Borders::ALL)
1282 .border_style(Style::default().fg(border_color)))
1283 .wrap(Wrap { trim: false });
1284 frame.render_widget(Clear, area);
1285 frame.render_widget(panel, area);
1286 }
1287 }
1288}
1289
1290fn frames_to_timecode(frame: usize, total: usize, fps: f64) -> (String, String) {
1292 let tc = |f: usize| -> String {
1293 let total_s = if fps > 0.0 { f as f64 / fps } else { 0.0 };
1294 let h = (total_s / 3600.0) as u64;
1295 let m = ((total_s % 3600.0) / 60.0) as u64;
1296 let s = (total_s % 60.0) as u64;
1297 let frames = (total_s.fract() * fps) as u64;
1298 format!("{:02}:{:02}:{:02}:{:02}", h, m, s, frames)
1299 };
1300 (tc(frame), tc(total))
1301}
1302
1303fn sprocket_track(frame: usize, total: usize, width: usize, _prev_playhead: Option<usize>) -> Line<'static> {
1306 if width < 8 || total == 0 {
1307 return Line::from("");
1308 }
1309 let capacity = width.saturating_sub(2);
1310 let playhead_pos = if total > 0 {
1311 (frame as f64 / total as f64) * capacity as f64
1312 } else {
1313 0.0
1314 };
1315 let playhead_idx = (playhead_pos as usize).min(capacity.saturating_sub(1));
1316 let tick_interval = (capacity / total.min(capacity)).max(1);
1317 let mut chars = Vec::with_capacity(width);
1318 chars.push(Span::raw("┊"));
1319 for i in 0..capacity {
1320 if i == playhead_idx {
1321 chars.push(Span::styled("●", Style::default().fg(Palette::ACCENT_AMBER)));
1322 } else if i % tick_interval == 0 && i < capacity - 1 {
1323 chars.push(Span::styled("╎", Style::default().fg(Palette::TEXT_SECONDARY)));
1324 } else {
1325 chars.push(Span::styled(".", Style::default().fg(Palette::BORDER_DIM)));
1326 }
1327 }
1328 chars.push(Span::raw("┊"));
1329 Line::from(chars)
1330}
1331
1332fn info_panel_lines<'a>(info: Option<&'a McrawFileInfo>, label_style: Style, value_style: Style, app: &'a App, avail_w: u16) -> Vec<Line<'a>> {
1334 let mut lines = Vec::new();
1335 if let Some(info) = info {
1336 let duration_secs = if info.fps > 0.0 { info.frame_count as f64 / info.fps } else { 0.0 };
1337 let mins = duration_secs as u64 / 60;
1338 let secs = duration_secs as u64 % 60;
1339 let inner_w = (info.width.max(info.height) as f32 / info.width.min(info.height) as f32).round() as usize;
1340
1341 lines.push(Line::from(vec![
1342 Span::styled("Resolution: ", label_style),
1343 Span::styled(format!("{} x {}", info.width, info.height), value_style),
1344 ]));
1345 lines.push(Line::from(vec![
1346 Span::styled("Frames: ", label_style),
1347 Span::styled(format!("{}", info.frame_count), value_style),
1348 Span::raw(" "),
1349 Span::styled("FPS: ", label_style),
1350 Span::styled(format!("{:.1}", info.fps), value_style),
1351 ]));
1352 lines.push(Line::from(vec![
1353 Span::styled("Duration: ", label_style),
1354 Span::styled(format!("{:02}:{:02}", mins, secs), value_style),
1355 ]));
1356 if let Some(ref cam) = info.camera_metadata.camera_model {
1357 if !cam.is_empty() {
1358 lines.push(Line::from(vec![
1359 Span::styled("Camera: ", label_style),
1360 Span::styled(cam.as_str(), value_style),
1361 ]));
1362 }
1363 }
1364 if let Some(iso) = info.camera_metadata.iso {
1365 lines.push(Line::from(vec![
1366 Span::styled("ISO: ", label_style),
1367 Span::styled(iso.to_string(), value_style),
1368 ]));
1369 }
1370
1371 } else {
1372 lines.push(Line::from(Span::styled(" Select a file from media pool", Style::default().fg(Color::DarkGray))));
1373 }
1374 lines
1375}
1376
1377fn gradient_slider(label: &str, label_w: usize, value: f32, lo: f32, hi: f32, display: String,
1388 track_w: usize, is_focused: bool, anim_offset: u8) -> Line<'static> {
1389 let dither = ["█", "▓", "▒", "░"];
1390 let normalized = if hi > lo { ((value - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 0.5 };
1391 let filled = (normalized * track_w as f32).round() as usize;
1392 let thumb_color = if is_focused {
1393 Palette::ACCENT_AMBER
1394 } else {
1395 Palette::TEXT_SECONDARY
1396 };
1397
1398 let mut spans = Vec::with_capacity(label_w + track_w + 16);
1399
1400 let padded = format!("{:width$}", label, width = label_w);
1402 spans.push(Span::styled(
1403 format!(" {}", padded),
1404 Style::default().fg(if is_focused { Palette::ACCENT_AMBER } else { Palette::TEXT_PRIMARY }),
1405 ));
1406
1407 spans.push(Span::styled("▐", Style::default().fg(Palette::BORDER_DIM)));
1409
1410 for i in 0..track_w {
1412 let t = i as f32 / track_w.saturating_sub(1).max(1) as f32;
1413 if i < filled {
1414 let c = dither[((i + anim_offset as usize) % 4)];
1415 let color = multi_stop_color(GRADIENT_WARM, t);
1416 spans.push(Span::styled(c, Style::default().fg(color)));
1417 } else {
1418 spans.push(Span::styled("░", Style::default().fg(Palette::BORDER_DIM)));
1419 }
1420 }
1421 spans.push(Span::styled("▌", Style::default().fg(Palette::BORDER_DIM)));
1423
1424 spans.push(Span::raw(" "));
1426 spans.push(Span::styled(display, Style::default().fg(thumb_color)));
1427
1428 Line::from(spans)
1429}
1430
1431fn render_grade_panel(frame: &mut Frame, app: &App, area: Rect, border_color: Color) {
1432 let inner_w = area.width.saturating_sub(6) as usize;
1433 let track_w = inner_w.min(35).max(10);
1434 let label_w = 12; let mut lines: Vec<Line> = Vec::new();
1437 lines.push(Line::from(Span::styled(
1438 " GRADE",
1439 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
1440 )));
1441 lines.push(Line::from(Span::styled(
1442 " \u{2191}\u{2193} category \u{2190}\u{2192} adjust",
1443 Style::default().fg(Palette::TEXT_SECONDARY),
1444 )));
1445 lines.push(Line::from(""));
1446
1447 for i in 0..crate::app::GradeSliders::count() {
1448 let name = crate::app::GradeSliders::name(i);
1449 let val = app.grade_sliders.value(i);
1450 let lo = crate::app::GradeSliders::min(i);
1451 let hi = crate::app::GradeSliders::max(i);
1452 let display = app.grade_sliders.display_value(i);
1453 let is_focused = app.focus_target == FocusTarget::Grade && app.grade_focus == i;
1454 lines.push(gradient_slider(name, label_w, val, lo, hi, display, track_w, is_focused, app.progress_anim_offset));
1455 }
1456
1457 let panel = Paragraph::new(lines)
1458 .block(
1459 Block::default()
1460 .title(" Grade ")
1461 .borders(Borders::ALL)
1462 .border_style(Style::default().fg(border_color)),
1463 )
1464 .wrap(Wrap { trim: false });
1465 frame.render_widget(panel, area);
1466}
1467
1468fn shockwave_border(ticks: u8, normal: Color) -> Color {
1472 if ticks >= 28 {
1473 Color::Rgb(0xFF, 0xF8, 0xD0)
1474 } else if ticks >= 24 {
1475 Color::Rgb(0xF8, 0xEE, 0xA0)
1476 } else if ticks >= 20 {
1477 Color::Rgb(0xF0, 0xE6, 0x8C)
1478 } else if ticks >= 16 {
1479 Color::Rgb(0xE0, 0xD0, 0x78)
1480 } else if ticks >= 12 {
1481 Color::Rgb(0xD0, 0xBC, 0x64)
1482 } else if ticks >= 9 {
1483 Color::Rgb(0xC0, 0xA8, 0x50)
1484 } else if ticks >= 6 {
1485 Color::Rgb(0xB0, 0x94, 0x3C)
1486 } else if ticks >= 4 {
1487 Color::Rgb(0xA0, 0x80, 0x28)
1488 } else if ticks >= 2 {
1489 Color::Rgb(0x90, 0x6C, 0x14)
1490 } else {
1491 normal
1492 }
1493}
1494
1495fn focus_strip<'a>(app: &'a App, width: u16) -> Line<'a> {
1498 let active = app.grade_strip_active || app.grade_strip_idle_ticks > 0;
1499
1500 let file_name = app.file_path.as_ref()
1501 .map(|s| std::path::Path::new(s))
1502 .and_then(|p| p.file_name())
1503 .and_then(|n| n.to_str())
1504 .unwrap_or("untitled");
1505
1506 if !active {
1507 Line::from(vec![
1509 Span::styled(" ◆ GRADE ACTIVE ", Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)),
1510 Span::raw("│ "),
1511 Span::styled(file_name, Style::default().fg(Palette::TEXT_PRIMARY).add_modifier(Modifier::BOLD)),
1512 Span::raw(" │ "),
1513 Span::styled("[j/k]", Style::default().fg(Palette::STATUS_KEY)),
1514 Span::styled(" Param ", Style::default().fg(Palette::TEXT_SECONDARY)),
1515 Span::styled("[h/l]", Style::default().fg(Palette::STATUS_KEY)),
1516 Span::styled(" Value ", Style::default().fg(Palette::TEXT_SECONDARY)),
1517 Span::styled("[r]", Style::default().fg(Palette::STATUS_KEY)),
1518 Span::styled(" Reset ", Style::default().fg(Palette::TEXT_SECONDARY)),
1519 Span::styled("[b]", Style::default().fg(Palette::STATUS_KEY)),
1520 Span::styled(" Before ", Style::default().fg(Palette::TEXT_SECONDARY)),
1521 Span::styled("[Esc]", Style::default().fg(Palette::STATUS_KEY)),
1522 Span::styled(" Exit", Style::default().fg(Palette::TEXT_SECONDARY)),
1523 ])
1524 } else {
1525 let i = app.grade_focus;
1527 let name = crate::app::GradeSliders::name(i);
1528 let norm = app.grade_sliders.normalized(i);
1529 let display = app.grade_sliders.display_value(i);
1530
1531 let track_w = (width as usize / 3).max(20).min(60);
1532 let thumb_pos = (norm * track_w as f32).round() as usize;
1533 let dither = ["█", "▓", "▒", "░"];
1534 let is_temp_or_tint = i == 5 || i == 6;
1535
1536 let mut track_spans: Vec<Span<'static>> = Vec::with_capacity(track_w + 2);
1537 track_spans.push(Span::styled("▐", Style::default().fg(Palette::BORDER_DIM)));
1538
1539 for pos in 0..track_w {
1540 let t = pos as f32 / track_w.max(1) as f32;
1541 let color = multi_stop_color(if is_temp_or_tint { GRADIENT_COOL } else { GRADIENT_WARM }, t);
1542
1543 let has_phosphor = app.phosphor_trail.iter()
1544 .any(|&(pt, _)| (pt * track_w as f32 - pos as f32).abs() < 0.6);
1545
1546 if pos == thumb_pos {
1547 track_spans.push(Span::styled("●", Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)));
1548 } else if has_phosphor {
1549 track_spans.push(Span::styled("░", Style::default().fg(Palette::ACCENT_AMBER)));
1550 } else if pos < thumb_pos {
1551 let di = ((pos + app.progress_anim_offset as usize) % 4).min(3);
1552 track_spans.push(Span::styled(dither[di], Style::default().fg(color)));
1553 } else {
1554 track_spans.push(Span::styled(" ", Style::default().fg(color)));
1555 }
1556 }
1557
1558 track_spans.push(Span::styled("▌", Style::default().fg(Palette::BORDER_DIM)));
1559
1560 let name_style = if let Some((old_idx, ticks)) = app.grade_morph {
1562 if old_idx == i {
1563 let bright = (4 - ticks) as f32 / 4.0;
1564 let bri = 0.5 + bright * 0.5;
1565 let r = (0xE8u8 as f32 * bri) as u8;
1566 let g = (0xA0u8 as f32 * (0.5 + bright * 0.3)) as u8;
1567 let b = (0x35u8 as f32 * (0.5 + bright * 0.3)) as u8;
1568 Style::default().fg(Color::Rgb(r, g, b)).add_modifier(Modifier::BOLD)
1569 } else {
1570 Style::default().fg(Palette::TEXT_SECONDARY)
1571 }
1572 } else {
1573 Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)
1574 };
1575
1576 Line::from({
1577 let mut line_spans: Vec<Span<'static>> = vec![
1578 Span::raw(" "),
1579 Span::styled("◆", Style::default().fg(Palette::ACCENT_AMBER).add_modifier(Modifier::BOLD)),
1580 Span::raw(" "),
1581 Span::styled(name, name_style),
1582 Span::raw(" "),
1583 ];
1584 line_spans.extend(track_spans);
1585 line_spans.extend(vec![
1586 Span::raw(" "),
1587 Span::styled(display, Style::default().fg(Palette::ACCENT_AMBER)),
1588 Span::raw(" │ "),
1589 Span::styled("[j/k]", Style::default().fg(Palette::STATUS_KEY)),
1590 Span::raw(" "),
1591 Span::styled("[h/l]", Style::default().fg(Palette::STATUS_KEY)),
1592 Span::raw(" "),
1593 Span::styled("[r]", Style::default().fg(Palette::STATUS_KEY)),
1594 Span::raw(" Reset"),
1595 ]);
1596 line_spans
1597 })
1598 }
1599}
1600
1601fn gradient_progress_bar(percent: f64, width: usize, _anim_offset: u8) -> Vec<Span<'static>> {
1607 let dither = ["█", "▓", "▒", "░"];
1608 let pct = percent.clamp(0.0, 100.0) / 100.0;
1609 let exact_filled = pct * width as f64;
1610 let filled = exact_filled as usize;
1611 let frac = exact_filled - filled as f64; let mut spans = Vec::with_capacity(width);
1613
1614 for i in 0..width {
1615 let t = i as f32 / (width as f32).max(1.0);
1616 let color = multi_stop_color(GRADIENT_WARM, t);
1617 let dither_idx = if i < filled {
1618 0
1620 } else if i == filled && frac > 0.001 {
1621 let head_step = (frac * 3.0).round() as usize; (head_step + 1).min(3) } else {
1625 3
1627 };
1628 spans.push(Span::styled(dither[dither_idx], Style::new().fg(color)));
1629 }
1630 spans
1631}
1632
1633fn render_render_progress(frame: &mut Frame, app: &App, area: Rect, border_color: Color) {
1634 let pct = app.export_progress;
1635 let bar_width = area.width.saturating_sub(4) as usize;
1636 let bar_spans = gradient_progress_bar(pct, bar_width, app.progress_anim_offset);
1637
1638 let elapsed = app.export_start_time
1639 .map(|t| t.elapsed())
1640 .unwrap_or_default();
1641 let elapsed_secs = elapsed.as_secs();
1642 let elapsed_mins = elapsed_secs / 60;
1643 let elapsed_remain = elapsed_secs % 60;
1644 let elapsed_str = format!("{:02}:{:02}", elapsed_mins, elapsed_remain);
1645
1646 let est_total_secs = if pct > 0.0 {
1647 (elapsed.as_secs_f64() / pct * 100.0) as u64
1648 } else {
1649 0
1650 };
1651 let est_remaining = est_total_secs.saturating_sub(elapsed_secs);
1652 let est_mins = est_remaining / 60;
1653 let est_remain = est_remaining % 60;
1654 let eta_str = format!("{:02}:{:02}", est_mins, est_remain);
1655
1656 let text = vec![
1657 Line::from(Span::styled(format!(" {} Rendering", crate::app::SPINNER_FRAMES[app.spinner_frame as usize % crate::app::SPINNER_FRAMES.len()]), Style::default().fg(Palette::QUEUE_RENDERING).add_modifier(Modifier::BOLD))),
1658 Line::from(""),
1659 Line::from(vec![Span::raw(" ")].into_iter().chain(bar_spans.into_iter()).collect::<Vec<_>>()),
1660 Line::from(""),
1661 Line::from(Span::styled(format!(" {:.1}% | Elapsed: {} | ETA: {}", pct, elapsed_str, eta_str), Style::default().fg(Palette::SUCCESS).add_modifier(Modifier::BOLD))),
1662 Line::from(""),
1663 Line::from(Span::styled(" Press [x] / [v] / Ctrl+X to cancel", Style::default().fg(Color::DarkGray))),
1664 ];
1665
1666 let panel = Paragraph::new(text)
1667 .block(
1668 Block::default()
1669 .title(" Render Progress ")
1670 .borders(Borders::ALL)
1671 .border_style(Style::default().fg(border_color)),
1672 );
1673 frame.render_widget(panel, area);
1674}
1675
1676fn render_export_settings(frame: &mut Frame, app: &App, area: Rect, regions: &mut Vec<ClickRegion>) {
1681 let is_focused = app.focus_target == FocusTarget::ExportSettings;
1682 let border_color = shockwave_border(app.shockwave_ticks_remaining, if is_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER });
1683 let show_rate = !matches!(app.export_codec_family, CodecFamily::ProRes | CodecFamily::DNxHR);
1684
1685 regions.push(ClickRegion { area, action: ClickAction::FocusExport });
1687
1688 let mut lines = vec![
1689 Line::from(Span::styled(" Export Settings", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))),
1690 Line::from(""),
1691 ];
1692
1693 let preset_label = "Preset:";
1700 let preset_value = match &app.active_preset {
1701 Some(name) => {
1702 let matches = app.current_matches_preset(name);
1703 let marker = if matches { "●" } else { "○" };
1704 let status = if matches { " (in sync)" } else { " (modified)" };
1705 format!("{} {}{}", marker, name, status)
1706 }
1707 None => "(none — press P to pick or p to save current)".to_string(),
1708 };
1709 let preset_value_display = truncate_to_width(&preset_value, max_value_width(area.width, preset_label));
1710 lines.push(Line::from(Span::styled(
1711 format!(" {} {}", preset_label, preset_value_display),
1712 Style::default().fg(Palette::LABEL),
1713 )));
1714 lines.push(Line::from(""));
1715
1716 let base_y = area.y + 5;
1720
1721 let preset_area = Rect {
1725 x: area.x + 1,
1726 y: area.y + 3,
1727 width: area.width.saturating_sub(2),
1728 height: 1,
1729 };
1730 regions.push(ClickRegion {
1731 area: preset_area,
1732 action: ClickAction::OpenPresetPicker,
1733 });
1734
1735 let co_focused = app.export_focus == ExportFocus::CodecFamily && is_focused;
1737 let codec_name = app.export_codec_family.name();
1738 let codec_style = if co_focused {
1739 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1740 } else if is_codec_hw_available(app) {
1741 Style::default().fg(Palette::HW_CODEC).add_modifier(Modifier::BOLD)
1742 } else {
1743 Style::default().fg(Palette::SW_CODEC)
1744 };
1745 let codec_suffix = if is_codec_hw_available(app) { " [HW]" } else { " [SW]" };
1746 let codec_value = format!("{}{}", codec_name, codec_suffix);
1747 let codec_display = truncate_to_width(&codec_value, max_value_width(area.width, "Codec:"));
1748 lines.push(Line::from(vec![
1749 Span::styled(" Codec: ", Style::default().fg(Palette::LABEL)),
1750 Span::styled(codec_display, codec_style),
1751 ]));
1752 let co_area = Rect { x: area.x + 1, y: base_y, width: area.width.saturating_sub(2), height: 1 };
1753 regions.push(ClickRegion { area: co_area, action: ClickAction::CycleCodec });
1754
1755 let cs_focused = app.export_focus == ExportFocus::ColorSpace && is_focused;
1757 let gamut_display = truncate_to_width(app.export_color_space.name(), max_value_width(area.width, "Gamut:"));
1758 lines.push(Line::from(vec![
1759 Span::styled(" Gamut: ", Style::default().fg(Palette::LABEL)),
1760 Span::styled(gamut_display, if cs_focused {
1761 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1762 } else {
1763 Style::default().fg(Palette::VALUE)
1764 }),
1765 ]));
1766 let cs_area = Rect { x: area.x + 1, y: base_y + 1, width: area.width.saturating_sub(2), height: 1 };
1767 regions.push(ClickRegion { area: cs_area, action: ClickAction::CycleGamut });
1768
1769 let tf_focused = app.export_focus == ExportFocus::TransferFunction && is_focused;
1771 let tf_display = truncate_to_width(app.export_transfer_function.name(), max_value_width(area.width, "Transfer:"));
1772 lines.push(Line::from(vec![
1773 Span::styled(" Transfer: ", Style::default().fg(Palette::LABEL)),
1774 Span::styled(tf_display, if tf_focused {
1775 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1776 } else {
1777 Style::default().fg(Palette::VALUE)
1778 }),
1779 ]));
1780 let tf_area = Rect { x: area.x + 1, y: base_y + 2, width: area.width.saturating_sub(2), height: 1 };
1781 regions.push(ClickRegion { area: tf_area, action: ClickAction::CycleTransfer });
1782
1783 let pr_focused = app.export_focus == ExportFocus::Profile && is_focused;
1785 let profile_display = truncate_to_width(app.active_profile_name(), max_value_width(area.width, "Profile:"));
1786 lines.push(Line::from(vec![
1787 Span::styled(" Profile: ", Style::default().fg(Palette::LABEL)),
1788 Span::styled(profile_display, if pr_focused {
1789 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1790 } else {
1791 Style::default().fg(Palette::VALUE)
1792 }),
1793 ]));
1794 let pr_area = Rect { x: area.x + 1, y: base_y + 3, width: area.width.saturating_sub(2), height: 1 };
1795 regions.push(ClickRegion { area: pr_area, action: ClickAction::CycleProfile });
1796
1797 let fps_focused = app.export_focus == ExportFocus::Fps && is_focused;
1799 let fps_label_val = crate::app::App::fps_label(app.export_fps);
1800 let fps_display = truncate_to_width(&fps_label_val, max_value_width(area.width, "FPS:"));
1801 lines.push(Line::from(vec![
1802 Span::styled(" FPS: ", Style::default().fg(Palette::LABEL)),
1803 Span::styled(fps_display, if fps_focused {
1804 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1805 } else {
1806 Style::default().fg(Palette::VALUE)
1807 }),
1808 ]));
1809 let fps_area = Rect { x: area.x + 1, y: base_y + 4, width: area.width.saturating_sub(2), height: 1 };
1810 regions.push(ClickRegion { area: fps_area, action: ClickAction::CycleFps });
1811
1812 if show_rate {
1814 let rc_focused = app.export_focus == ExportFocus::RateControl && is_focused;
1815 let rate_display = truncate_to_width(&app.active_rate_control.name(), max_value_width(area.width, "Rate:"));
1816 lines.push(Line::from(vec![
1817 Span::styled(" Rate: ", Style::default().fg(Palette::LABEL)),
1818 Span::styled(rate_display, if rc_focused {
1819 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD)
1820 } else {
1821 Style::default().fg(Palette::VALUE)
1822 }),
1823 ]));
1824 let rc_area = Rect { x: area.x + 1, y: base_y + 5, width: area.width.saturating_sub(2), height: 1 };
1825 regions.push(ClickRegion { area: rc_area, action: ClickAction::CycleRate });
1826 }
1827
1828 lines.push(Line::from(""));
1829 if let Some(ref folder) = app.export_folder {
1830 let disp = folder.to_string_lossy().to_string();
1831 let out_max = max_value_width(area.width, "OutFolder:");
1832 let out_display = truncate_to_width(&disp, out_max);
1833 lines.push(Line::from(vec![
1834 Span::styled(" OutFolder: ", Style::default().fg(Palette::LABEL)),
1835 Span::styled(out_display, Style::default().fg(Palette::VALUE)),
1836 ]));
1837 } else {
1838 let hint = "(default) [o] set via browser";
1839 let out_max = max_value_width(area.width, "OutFolder:");
1840 let out_display = truncate_to_width(hint, out_max);
1841 lines.push(Line::from(Span::styled(
1842 format!(" OutFolder: {}", out_display),
1843 Style::default().fg(Palette::LABEL),
1844 )));
1845 }
1846 lines.push(Line::from(Span::styled(" [c] Codec [g] Gamut [t] Transfer [f] FPS [r] Rate [P] Preset [p] Save", Style::default().fg(Color::White))));
1847
1848 let panel = Paragraph::new(lines)
1849 .block(
1850 Block::default()
1851 .title(" Export Config ")
1852 .borders(Borders::ALL)
1853 .border_style(Style::default().fg(border_color)),
1854 )
1855 .wrap(Wrap { trim: false });
1856 frame.render_widget(panel, area);
1857}
1858
1859fn is_codec_hw_available(app: &App) -> bool {
1860 match app.export_codec_family {
1861 CodecFamily::HEVC => app.hardware_caps.hevc_is_hw,
1862 CodecFamily::H264 => app.hardware_caps.h264_is_hw,
1863 CodecFamily::AV1 => app.hardware_caps.av1_is_hw,
1864 CodecFamily::ProRes => app.hardware_caps.prores_is_hw,
1865 CodecFamily::DNxHR | CodecFamily::VP9 => false,
1866 }
1867}
1868
1869fn max_value_width(panel_width: u16, label: &str) -> usize {
1873 let inner = panel_width.saturating_sub(2) as usize;
1876 let reserved = 2 + label.chars().count() + 1;
1877 inner.saturating_sub(reserved).max(1)
1878}
1879
1880fn truncate_to_width(s: &str, max_chars: usize) -> String {
1884 let count = s.chars().count();
1885 if count <= max_chars {
1886 return s.to_string();
1887 }
1888 if max_chars <= 1 {
1889 return "…".to_string();
1890 }
1891 let keep = max_chars - 1;
1892 let mut out: String = s.chars().take(keep).collect();
1893 out.push('…');
1894 out
1895}
1896
1897fn render_queue_panel(frame: &mut Frame, app: &App, area: Rect, regions: &mut Vec<ClickRegion>) {
1902 let is_focused = app.focus_target == FocusTarget::Queue;
1903 let base = if is_focused { Palette::BORDER_FOCUSED } else { Palette::BORDER };
1904 let border_color = shockwave_border(app.shockwave_ticks_remaining, base);
1905 let inner_h = area.height.saturating_sub(2) as usize;
1906
1907 regions.push(ClickRegion { area, action: ClickAction::FocusQueue });
1909
1910 if app.queue.is_empty() {
1911 let placeholder = Paragraph::new(vec![
1912 Line::from(""),
1913 Line::from(Span::styled(" No jobs in queue", Style::default().fg(Color::DarkGray))),
1914 Line::from(Span::styled(" Select files and press [a] to add", Style::default().fg(Color::DarkGray))),
1915 ]).block(
1916 Block::default()
1917 .title(" Render Queue ")
1918 .borders(Borders::ALL)
1919 .border_style(Style::default().fg(border_color)),
1920 );
1921 frame.render_widget(placeholder, area);
1922 } else {
1923 let items: Vec<ListItem> = app.queue.iter().enumerate().map(|(_i, q)| {
1924 let name = q.path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(&q.path);
1925 let checkbox = if q.selected {
1926 Span::styled("◉ ", Style::default().fg(Palette::CHECKED).add_modifier(Modifier::BOLD))
1927 } else {
1928 Span::styled("◌ ", Style::default().fg(Palette::UNCHECKED))
1929 };
1930 let shockwave_flash = app.shockwave_ticks_remaining > 0
1931 && matches!(q.status, QueueStatus::Completed);
1932 let (status_color, status_text) = match &q.status {
1933 QueueStatus::Waiting => (Palette::QUEUE_WAITING, "Waiting"),
1934 QueueStatus::Rendering => (Palette::QUEUE_RENDERING, "Rendering"),
1935 QueueStatus::Completed if shockwave_flash => (Palette::ACCENT_EMBER, "✓ Done"),
1936 QueueStatus::Completed => (Palette::QUEUE_COMPLETED, "✓ Done"),
1937 QueueStatus::Failed(_) => (Palette::QUEUE_FAILED, "✗ Failed"),
1938 };
1939 let progress_str = if matches!(q.status, QueueStatus::Rendering) {
1940 format!("{:.0}%", q.progress)
1941 } else {
1942 status_text.to_string()
1943 };
1944 let content = Line::from(vec![
1945 checkbox,
1946 Span::styled(name, Style::default().fg(Color::White)),
1947 Span::raw(" "),
1948 Span::styled(app.export_codec_family.name(), Style::default().fg(Color::Cyan)),
1949 Span::raw(" "),
1950 Span::styled(progress_str, Style::default().fg(status_color)),
1951 ]);
1952 ListItem::new(content)
1953 }).collect();
1954
1955 let item_count = app.queue.len();
1956
1957 let has_room_for_buttons = inner_h >= 3;
1959 let visible_items = if has_room_for_buttons { inner_h - 1 } else { inner_h };
1960
1961 let list = List::new(items)
1962 .block(
1963 Block::default()
1964 .title(format!(" Render Queue ({}) ", app.queue.len()))
1965 .borders(Borders::ALL)
1966 .border_style(Style::default().fg(border_color)),
1967 )
1968 .highlight_style(
1969 if is_focused {
1970 Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD).bg(Palette::HIGHLIGHT_FOCUSED_BG)
1971 } else {
1972 Style::default().fg(Color::White).bg(Palette::HIGHLIGHT_BG)
1973 },
1974 )
1975 .highlight_symbol("> ");
1976
1977 let mut state = ListState::default();
1978 state.select(Some(app.queue_index));
1979 frame.render_stateful_widget(list, area, &mut state);
1980
1981 let visible_start = if app.queue_index >= visible_items {
1983 app.queue_index - visible_items + 1
1984 } else {
1985 0
1986 };
1987
1988 for i in 0..visible_items.min(item_count) {
1989 let entry_index = visible_start + i;
1990 if entry_index >= item_count {
1991 break;
1992 }
1993 let row_y = area.y + 1 + i as u16;
1994 let cb_area = Rect { x: area.x + 2, y: row_y, width: 4, height: 1 };
1995 regions.push(ClickRegion { area: cb_area, action: ClickAction::ToggleQueueSelection(entry_index) });
1996 let row_area = Rect { x: area.x + 6, y: row_y, width: area.width.saturating_sub(8), height: 1 };
1997 regions.push(ClickRegion { area: row_area, action: ClickAction::SelectQueueItem(entry_index) });
1998 }
1999
2000 if has_room_for_buttons {
2002 let btn_y = area.y + area.height.saturating_sub(2);
2003 let btn_row = Rect {
2004 x: area.x + 2,
2005 y: btn_y,
2006 width: area.width.saturating_sub(4),
2007 height: 1,
2008 };
2009
2010 let render_btn = Rect { x: btn_row.x, y: btn_row.y, width: 12, height: 1 };
2011 regions.push(ClickRegion { area: render_btn, action: ClickAction::RenderSelected });
2012
2013 let all_btn = Rect { x: btn_row.x + 13, y: btn_row.y, width: 8, height: 1 };
2014 regions.push(ClickRegion { area: all_btn, action: ClickAction::RenderAll });
2015
2016 let clear_btn = Rect { x: btn_row.x + 22, y: btn_row.y, width: 10, height: 1 };
2017 regions.push(ClickRegion { area: clear_btn, action: ClickAction::ClearQueue });
2018
2019 frame.render_widget(
2020 Paragraph::new(Line::from(vec![
2021 Span::styled(" [v] Render ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
2022 Span::raw(" "),
2023 Span::styled(" [R] All ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
2024 Span::raw(" "),
2025 Span::styled(" [x] Clear ", Style::default().fg(Palette::BUTTON_FG).bg(Palette::BUTTON_BG).add_modifier(Modifier::BOLD)),
2026 ])),
2027 btn_row,
2028 );
2029 }
2030 }
2031}
2032
2033fn render_status(frame: &mut Frame, app: &App, area: Rect, regions: &mut Vec<ClickRegion>) {
2038 let mut hints = vec![
2039 Span::styled("[b]", Style::default().fg(Palette::STATUS_KEY)),
2040 Span::styled(" Browser ", Style::default().fg(Color::White)),
2041 Span::styled("[Space]", Style::default().fg(Palette::STATUS_KEY)),
2042 Span::styled(" Select ", Style::default().fg(Color::White)),
2043 Span::styled("[a]", Style::default().fg(Palette::STATUS_KEY)),
2044 Span::styled(" Add ", Style::default().fg(Color::White)),
2045 Span::styled("[Tab]", Style::default().fg(Palette::STATUS_KEY)),
2046 Span::styled(" Panel ", Style::default().fg(Color::White)),
2047 Span::styled("[v]", Style::default().fg(Palette::STATUS_KEY)),
2048 Span::styled(" Render ", Style::default().fg(Color::White)),
2049 Span::styled("[?]", Style::default().fg(Palette::STATUS_KEY)),
2050 Span::styled(" Help ", Style::default().fg(Color::White)),
2051 Span::styled("[C]", Style::default().fg(Palette::STATUS_KEY)),
2052 Span::styled(" Culling ", Style::default().fg(Color::White)),
2053 ];
2054 if app.show_browser {
2055 hints.push(Span::styled("[I]", Style::default().fg(Palette::STATUS_KEY)));
2056 hints.push(Span::styled(" Import ", Style::default().fg(Color::White)));
2057 hints.push(Span::styled("[L]", Style::default().fg(Palette::STATUS_KEY)));
2058 hints.push(Span::styled(" Load All ", Style::default().fg(Color::White)));
2059 hints.push(Span::styled("[o]", Style::default().fg(Palette::STATUS_KEY)));
2060 hints.push(Span::styled(" OutFolder ", Style::default().fg(Color::White)));
2061 hints.push(Span::styled("[F]", Style::default().fg(Palette::STATUS_KEY)));
2062 hints.push(Span::styled(" Fav ", Style::default().fg(Color::White)));
2063 }
2064
2065 let msg = if !app.status_message.is_empty() {
2066 format!(" {} | ", app.status_message)
2067 } else {
2068 String::new()
2069 };
2070 let mut all_spans = vec![Span::styled(msg, Style::default().fg(Color::White))];
2071 all_spans.extend(hints);
2072
2073 let border_color = if let Some(drop_time) = app.drop_highlight {
2075 if drop_time.elapsed() < Duration::from_millis(800) {
2076 Color::Green
2077 } else {
2078 Palette::BORDER
2079 }
2080 } else {
2081 Palette::BORDER
2082 };
2083
2084 let status = Paragraph::new(Line::from(all_spans))
2085 .block(
2086 Block::default()
2087 .borders(Borders::ALL)
2088 .border_style(Style::default().fg(border_color)),
2089 );
2090 frame.render_widget(status, area);
2091}
2092
2093fn render_import_popup(frame: &mut Frame, area: Rect, app: &App, regions: &mut Vec<ClickRegion>) {
2098 let popup_area = centered_rect(65, 45, area);
2099 frame.render_widget(Clear, popup_area);
2100
2101 let mut lines = vec![
2102 Line::from(Span::styled(" Import .mcraw files", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))),
2103 Line::from(""),
2104 ];
2105
2106 let mut opt1_idx: Option<usize> = None;
2107 let mut opt2_idx: Option<usize> = None;
2108
2109 if let ImportPopupState::DroppedFiles { files, folder, all_in_folder } = &app.import_popup {
2110 let dropped_count = files.len();
2111 let folder_count = all_in_folder.len();
2112 let has_option2 = folder_count > dropped_count;
2113
2114 if dropped_count == 1 {
2116 let name = files[0].split(std::path::MAIN_SEPARATOR).last().unwrap_or(&files[0]);
2117 lines.push(Line::from(Span::styled(format!(" Dropped: {}", name), Style::default().fg(Palette::VALUE))));
2118 } else {
2119 lines.push(Line::from(Span::styled(format!(" Dropped: {} file(s)", dropped_count), Style::default().fg(Palette::VALUE))));
2120 for path in files.iter().take(3) {
2121 let name = path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(path);
2122 lines.push(Line::from(Span::styled(format!(" - {}", name), Style::default().fg(Color::Gray))));
2123 }
2124 if dropped_count > 3 {
2125 lines.push(Line::from(Span::styled(format!(" ... and {} more", dropped_count - 3), Style::default().fg(Color::DarkGray))));
2126 }
2127 }
2128
2129 lines.push(Line::from(""));
2130 lines.push(Line::from(Span::styled(format!(" Folder: {}", folder), Style::default().fg(Color::DarkGray))));
2131 lines.push(Line::from(Span::styled(format!(" Total in folder: {} .mcraw files", folder_count), Style::default().fg(Color::DarkGray))));
2132 lines.push(Line::from(""));
2133
2134 lines.push(Line::from(Span::styled(" [1] Import dropped file(s) only", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))));
2135 opt1_idx = Some(lines.len() - 1);
2136
2137 if has_option2 {
2138 lines.push(Line::from(Span::styled(format!(" [2] Import all {} file(s) in folder", folder_count), Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))));
2139 opt2_idx = Some(lines.len() - 1);
2140 }
2141
2142 lines.push(Line::from(""));
2143 lines.push(Line::from(Span::styled(" Click, Enter, or 1/2 to select", Style::default().fg(Color::DarkGray))));
2144 }
2145
2146 let popup = Paragraph::new(lines)
2147 .block(
2148 Block::default()
2149 .title(" Import ")
2150 .borders(Borders::ALL)
2151 .border_style(Style::default().fg(Palette::POPUP_BORDER)),
2152 )
2153 .wrap(Wrap { trim: false });
2154 frame.render_widget(popup, popup_area);
2155
2156 if let Some(idx) = opt1_idx {
2161 regions.push(ClickRegion {
2162 area: Rect {
2163 x: popup_area.x + 2,
2164 y: popup_area.y + 1 + idx as u16,
2165 width: popup_area.width.saturating_sub(4),
2166 height: 1,
2167 },
2168 action: ClickAction::ImportOption1,
2169 });
2170 }
2171
2172 if let Some(idx) = opt2_idx {
2173 regions.push(ClickRegion {
2174 area: Rect {
2175 x: popup_area.x + 2,
2176 y: popup_area.y + 1 + idx as u16,
2177 width: popup_area.width.saturating_sub(4),
2178 height: 1,
2179 },
2180 action: ClickAction::ImportOption2,
2181 });
2182 }
2183}
2184
2185fn render_drop_preview(frame: &mut Frame, area: Rect, preview: &crate::app::DropPreview) {
2190 let elapsed = preview.start_time.elapsed();
2191 if elapsed >= Duration::from_secs(2) {
2192 return;
2193 }
2194
2195 let alpha = if elapsed > Duration::from_millis(1500) {
2197 1.0 - ((elapsed.as_millis() - 1500) as f32 / 500.0)
2198 } else {
2199 1.0
2200 };
2201
2202 let popup_area = centered_rect(50, 25.min(15 + preview.files.len() as u16), area);
2203 frame.render_widget(Clear, popup_area);
2204
2205 let mut lines = vec![
2206 Line::from(Span::styled(
2207 " Files Dropped",
2208 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
2209 )),
2210 Line::from(""),
2211 ];
2212
2213 let max_show = 5.min(preview.files.len());
2215 for (i, file) in preview.files.iter().take(max_show).enumerate() {
2216 let name = file.split(std::path::MAIN_SEPARATOR).last().unwrap_or(file);
2217 let icon = if i < max_show - 1 || preview.files.len() <= max_show {
2218 " ✓ "
2219 } else {
2220 " ✓ "
2221 };
2222 lines.push(Line::from(vec![
2223 Span::styled(icon, Style::default().fg(Color::Green)),
2224 Span::styled(name, Style::default().fg(Color::White)),
2225 ]));
2226 }
2227
2228 if preview.files.len() > max_show {
2229 lines.push(Line::from(Span::styled(
2230 format!(" ... and {} more", preview.files.len() - max_show),
2231 Style::default().fg(Color::DarkGray),
2232 )));
2233 }
2234
2235 lines.push(Line::from(""));
2236 lines.push(Line::from(Span::styled(
2237 " Importing...",
2238 Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
2239 )));
2240
2241 let border_color = if alpha > 0.5 { Color::Green } else { Color::DarkGray };
2242
2243 let popup = Paragraph::new(lines)
2244 .block(
2245 Block::default()
2246 .title(" Drop ")
2247 .borders(Borders::ALL)
2248 .border_style(Style::default().fg(border_color)),
2249 )
2250 .wrap(Wrap { trim: false })
2251 .alignment(Alignment::Left);
2252 frame.render_widget(popup, popup_area);
2253}
2254
2255fn render_full_info_overlay(frame: &mut Frame, area: Rect, app: &App) {
2260 let popup_area = centered_rect(75, 80, area);
2261 frame.render_widget(Clear, popup_area);
2262
2263 let info = app.focused_file_info().or(app.file_info.as_ref());
2264
2265 let lines = if let Some(info) = info {
2266 let mut lines = Vec::new();
2267
2268 lines.push(Line::from(Span::styled(
2270 " General",
2271 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
2272 )));
2273 let filename = info.path.split(std::path::MAIN_SEPARATOR).last().unwrap_or(&info.path);
2274 lines.push(Line::from(vec![
2275 Span::styled(" Filename: ", Style::default().fg(Palette::LABEL)),
2276 Span::styled(filename, Style::default().fg(Palette::VALUE)),
2277 ]));
2278 lines.push(Line::from(vec![
2279 Span::styled(" Path: ", Style::default().fg(Palette::LABEL)),
2280 Span::styled(&info.path, Style::default().fg(Palette::VALUE)),
2281 ]));
2282 lines.push(Line::from(vec![
2283 Span::styled(" Size: ", Style::default().fg(Palette::LABEL)),
2284 Span::styled(format_size(info.size), Style::default().fg(Palette::VALUE)),
2285 ]));
2286 lines.push(Line::from(vec![
2287 Span::styled(" Format: ", Style::default().fg(Palette::LABEL)),
2288 Span::styled(info.format_name(), Style::default().fg(Palette::VALUE)),
2289 ]));
2290 if let Some(ref date) = info.camera_metadata.capture_date {
2291 lines.push(Line::from(vec![
2292 Span::styled(" Capture Date: ", Style::default().fg(Palette::LABEL)),
2293 Span::styled(format_capture_date(date), Style::default().fg(Palette::VALUE)),
2294 ]));
2295 }
2296 lines.push(Line::from(""));
2297
2298 lines.push(Line::from(Span::styled(
2300 " Camera",
2301 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
2302 )));
2303 if let Some(ref model) = info.camera_metadata.camera_model {
2304 if !model.is_empty() {
2305 lines.push(Line::from(vec![
2306 Span::styled(" Camera: ", Style::default().fg(Palette::LABEL)),
2307 Span::styled(model, Style::default().fg(Palette::VALUE)),
2308 ]));
2309 }
2310 }
2311 if let Some(ref lens) = info.camera_metadata.lens_model {
2312 lines.push(Line::from(vec![
2313 Span::styled(" Lens: ", Style::default().fg(Palette::LABEL)),
2314 Span::styled(lens, Style::default().fg(Palette::VALUE)),
2315 ]));
2316 }
2317 if let Some(fl) = info.camera_metadata.focal_length {
2318 lines.push(Line::from(vec![
2319 Span::styled(" Focal Length: ", Style::default().fg(Palette::LABEL)),
2320 Span::styled(format!("{:.1}mm", fl), Style::default().fg(Palette::VALUE)),
2321 ]));
2322 }
2323 if let Some(ap) = info.camera_metadata.aperture {
2324 lines.push(Line::from(vec![
2325 Span::styled(" Aperture: ", Style::default().fg(Palette::LABEL)),
2326 Span::styled(format!("f/{:.1}", ap), Style::default().fg(Palette::VALUE)),
2327 ]));
2328 }
2329 if let Some(iso) = info.camera_metadata.iso {
2330 lines.push(Line::from(vec![
2331 Span::styled(" ISO: ", Style::default().fg(Palette::LABEL)),
2332 Span::styled(iso.to_string(), Style::default().fg(Palette::VALUE)),
2333 ]));
2334 }
2335 if let Some(et) = info.camera_metadata.exposure_time {
2336 lines.push(Line::from(vec![
2337 Span::styled(" Exposure: ", Style::default().fg(Palette::LABEL)),
2338 Span::styled(format_exposure_time(et), Style::default().fg(Palette::VALUE)),
2339 ]));
2340 }
2341 if let Some(wb) = info.camera_metadata.white_balance {
2342 lines.push(Line::from(vec![
2343 Span::styled(" White Balance:", Style::default().fg(Palette::LABEL)),
2344 Span::styled(format!("{:.0}K", wb), Style::default().fg(Palette::VALUE)),
2345 ]));
2346 }
2347 if let Some(ref cm) = info.camera_metadata.color_matrix {
2348 let vals: Vec<String> = cm.iter().map(|v| format!("{:.2}", v)).collect();
2349 lines.push(Line::from(vec![
2350 Span::styled(" Color Matrix1:", Style::default().fg(Palette::LABEL)),
2351 Span::styled(format!("[{}]", vals.join(", ")), Style::default().fg(Palette::VALUE)),
2352 ]));
2353 }
2354 if let Some(ref cm) = info.camera_metadata.color_matrix2 {
2355 let vals: Vec<String> = cm.iter().map(|v| format!("{:.2}", v)).collect();
2356 lines.push(Line::from(vec![
2357 Span::styled(" Color Matrix2:", Style::default().fg(Palette::LABEL)),
2358 Span::styled(format!("[{}]", vals.join(", ")), Style::default().fg(Palette::VALUE)),
2359 ]));
2360 }
2361 if let Some(i1) = info.camera_metadata.calibration_illuminant1 {
2362 if let Some(i2) = info.camera_metadata.calibration_illuminant2 {
2363 lines.push(Line::from(vec![
2364 Span::styled(" Cal Illuminants:", Style::default().fg(Palette::LABEL)),
2365 Span::styled(format!("{} / {}", i1, i2), Style::default().fg(Palette::VALUE)),
2366 ]));
2367 }
2368 }
2369 lines.push(Line::from(""));
2370
2371 lines.push(Line::from(Span::styled(
2373 " Video",
2374 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
2375 )));
2376 lines.push(Line::from(vec![
2377 Span::styled(" Resolution: ", Style::default().fg(Palette::LABEL)),
2378 Span::styled(format!("{}x{} ({})", info.width, info.height, info.resolution_label()), Style::default().fg(Palette::VALUE)),
2379 ]));
2380 lines.push(Line::from(vec![
2381 Span::styled(" FPS: ", Style::default().fg(Palette::LABEL)),
2382 Span::styled(format!("{:.2}", info.fps), Style::default().fg(Palette::VALUE)),
2383 ]));
2384 let duration_secs = if info.fps > 0.0 { info.frame_count as f64 / info.fps } else { 0.0 };
2385 lines.push(Line::from(vec![
2386 Span::styled(" Duration: ", Style::default().fg(Palette::LABEL)),
2387 Span::styled(format_duration(duration_secs), Style::default().fg(Palette::VALUE)),
2388 ]));
2389 lines.push(Line::from(vec![
2390 Span::styled(" Frames: ", Style::default().fg(Palette::LABEL)),
2391 Span::styled(info.frame_count.to_string(), Style::default().fg(Palette::VALUE)),
2392 ]));
2393 lines.push(Line::from(vec![
2394 Span::styled(" Bit Depth: ", Style::default().fg(Palette::LABEL)),
2395 Span::styled(format!("{}-bit", info.bit_depth), Style::default().fg(Palette::VALUE)),
2396 ]));
2397 lines.push(Line::from(vec![
2398 Span::styled(" Bayer: ", Style::default().fg(Palette::LABEL)),
2399 Span::styled(info.bayer_pattern.name(), Style::default().fg(Palette::VALUE)),
2400 ]));
2401 if info.active_width > 0 && info.active_height > 0 {
2402 lines.push(Line::from(vec![
2403 Span::styled(" Active Area: ", Style::default().fg(Palette::LABEL)),
2404 Span::styled(format!("{}x{} @({},{})", info.active_width, info.active_height, info.active_offset_x, info.active_offset_y), Style::default().fg(Palette::VALUE)),
2405 ]));
2406 }
2407 lines.push(Line::from(""));
2408
2409 lines.push(Line::from(Span::styled(
2411 " Audio",
2412 Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD),
2413 )));
2414 if info.has_audio {
2415 lines.push(Line::from(vec![
2416 Span::styled(" Has Audio: ", Style::default().fg(Palette::LABEL)),
2417 Span::styled("Yes", Style::default().fg(Palette::VALUE)),
2418 ]));
2419 if info.audio_sample_rate > 0 {
2420 lines.push(Line::from(vec![
2421 Span::styled(" Sample Rate: ", Style::default().fg(Palette::LABEL)),
2422 Span::styled(format!("{} Hz", info.audio_sample_rate), Style::default().fg(Palette::VALUE)),
2423 ]));
2424 }
2425 if info.audio_channels > 0 {
2426 let ch_name = if info.audio_channels == 1 {
2427 "mono"
2428 } else if info.audio_channels == 2 {
2429 "stereo"
2430 } else {
2431 "multi"
2432 };
2433 lines.push(Line::from(vec![
2434 Span::styled(" Channels: ", Style::default().fg(Palette::LABEL)),
2435 Span::styled(format!("{} ({})", info.audio_channels, ch_name), Style::default().fg(Palette::VALUE)),
2436 ]));
2437 }
2438 if let Some(length) = info.audio_length {
2439 lines.push(Line::from(vec![
2440 Span::styled(" Audio Length: ", Style::default().fg(Palette::LABEL)),
2441 Span::styled(format!("{} bytes", length), Style::default().fg(Palette::VALUE)),
2442 ]));
2443 }
2444 if let Some(offset) = info.audio_offset {
2445 lines.push(Line::from(vec![
2446 Span::styled(" Audio Offset: ", Style::default().fg(Palette::LABEL)),
2447 Span::styled(format!("{} bytes", offset), Style::default().fg(Palette::VALUE)),
2448 ]));
2449 }
2450 } else {
2451 lines.push(Line::from(vec![
2452 Span::styled(" Has Audio: ", Style::default().fg(Palette::LABEL)),
2453 Span::styled("No", Style::default().fg(Palette::VALUE)),
2454 ]));
2455 }
2456 lines.push(Line::from(""));
2457 lines.push(Line::from(Span::styled(" Press [i] or Esc to close", Style::default().fg(Color::DarkGray))));
2458
2459 lines
2460 } else {
2461 vec![
2462 Line::from(Span::styled(" FILE INFO", Style::default().fg(Palette::LABEL).add_modifier(Modifier::BOLD))),
2463 Line::from(""),
2464 Line::from(Span::styled(" No file selected", Style::default().fg(Color::DarkGray))),
2465 Line::from(""),
2466 Line::from(Span::styled(" Press [i] or Esc to close", Style::default().fg(Color::DarkGray))),
2467 ]
2468 };
2469
2470 let popup = Paragraph::new(lines)
2471 .block(
2472 Block::default()
2473 .title(" Full File Info (Esc/i to close) ")
2474 .borders(Borders::ALL)
2475 .border_style(Style::default().fg(Palette::POPUP_BORDER)),
2476 )
2477 .wrap(Wrap { trim: false });
2478 frame.render_widget(popup, popup_area);
2479}
2480
2481fn render_help_overlay(frame: &mut Frame, app: &App, area: Rect) {
2486 let popup_area = centered_rect(70, 70, area);
2487 frame.render_widget(Clear, popup_area);
2488
2489 let all_lines = vec![
2490 Line::from(Span::styled(" Keybindings", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))),
2491 Line::from(""),
2492 Line::from(Span::styled(" Navigation", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2493 Line::from(Span::styled(" b Toggle browser overlay", Style::default().fg(Palette::VALUE))),
2494 Line::from(Span::styled(" Tab Cycle focus: Media Pool -> Queue -> Export Settings", Style::default().fg(Palette::VALUE))),
2495 Line::from(Span::styled(" ↑/↓ or j/k Navigate lists (media pool, queue)", Style::default().fg(Palette::VALUE))),
2496 Line::from(Span::styled(" ←/→ or h/l Frame navigation / Export Settings value", Style::default().fg(Palette::VALUE))),
2497 Line::from(Span::styled(" Click Click panel or items to focus/select", Style::default().fg(Palette::VALUE))),
2498 Line::from(Span::styled(" Scroll Scroll wheel navigates the hovered panel", Style::default().fg(Palette::VALUE))),
2499 Line::from(""),
2500 Line::from(Span::styled(" Media Pool", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2501 Line::from(Span::styled(" Space Toggle selection checkbox", Style::default().fg(Palette::VALUE))),
2502 Line::from(Span::styled(" a Add selected to render queue", Style::default().fg(Palette::VALUE))),
2503 Line::from(Span::styled(" A Add ALL to render queue", Style::default().fg(Palette::VALUE))),
2504 Line::from(Span::styled(" d Remove current from media pool", Style::default().fg(Palette::VALUE))),
2505 Line::from(Span::styled(" D Remove ALL selected from media pool", Style::default().fg(Palette::VALUE))),
2506 Line::from(""),
2507 Line::from(Span::styled(" Render Queue", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2508 Line::from(Span::styled(" Space Toggle selection in queue", Style::default().fg(Palette::VALUE))),
2509 Line::from(Span::styled(" v Render selected items", Style::default().fg(Palette::VALUE))),
2510 Line::from(Span::styled(" R Render ALL items (sequential batch)", Style::default().fg(Palette::VALUE))),
2511 Line::from(Span::styled(" x Clear completed/failed", Style::default().fg(Palette::VALUE))),
2512 Line::from(Span::styled(" d Remove from queue", Style::default().fg(Palette::VALUE))),
2513 Line::from(""),
2514 Line::from(Span::styled(" Export Settings", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2515 Line::from(Span::styled(" e Focus export settings", Style::default().fg(Palette::VALUE))),
2516 Line::from(Span::styled(" ↑/↓ Cycle between settings (focus)", Style::default().fg(Palette::VALUE))),
2517 Line::from(Span::styled(" ←/→ Change value of focused setting", Style::default().fg(Palette::VALUE))),
2518 Line::from(Span::styled(" c/g/t/r Cycle codec/gamut/transfer/rate", Style::default().fg(Palette::VALUE))),
2519 Line::from(Span::styled(" P Open preset picker (apply saved preset)", Style::default().fg(Palette::VALUE))),
2520 Line::from(Span::styled(" p Save current settings as preset", Style::default().fg(Palette::VALUE))),
2521 Line::from(Span::styled(" i Edit custom rate (when export focused)", Style::default().fg(Palette::VALUE))),
2522 Line::from(""),
2523 Line::from(Span::styled(" Browser", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2524 Line::from(Span::styled(" Click/Dbl Select/Open file/folder", Style::default().fg(Palette::VALUE))),
2525 Line::from(Span::styled(" Enter Open selected file/folder", Style::default().fg(Palette::VALUE))),
2526 Line::from(Span::styled(" Space Toggle selection checkbox", Style::default().fg(Palette::VALUE))),
2527 Line::from(Span::styled(" I Import selected .mcraw", Style::default().fg(Palette::VALUE))),
2528 Line::from(Span::styled(" L Load all .mcraw in folder", Style::default().fg(Palette::VALUE))),
2529 Line::from(Span::styled(" o Set export folder to browser path", Style::default().fg(Palette::VALUE))),
2530 Line::from(Span::styled(" F Toggle favourite folder (current)", Style::default().fg(Palette::VALUE))),
2531 Line::from(Span::styled(" f Toggle favourites list view (keyboard nav)", Style::default().fg(Palette::VALUE))),
2532 Line::from(Span::styled(" Delete Remove selected favourite (in list view)", Style::default().fg(Palette::VALUE))),
2533 Line::from(Span::styled(" . Toggle hidden files", Style::default().fg(Palette::VALUE))),
2534 Line::from(""),
2535 Line::from(Span::styled(" Culling", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2536 Line::from(Span::styled(" C Toggle culling mode", Style::default().fg(Palette::VALUE))),
2537 Line::from(""),
2538 Line::from(Span::styled(" Grade (RAW Adjust)", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2539 Line::from(Span::styled(" G Toggle grade screen", Style::default().fg(Palette::VALUE))),
2540 Line::from(Span::styled(" ↑/↓ Focus previous/next slider", Style::default().fg(Palette::VALUE))),
2541 Line::from(Span::styled(" ←/→ Adjust slider value", Style::default().fg(Palette::VALUE))),
2542 Line::from(Span::styled(" r Reset focused slider to default", Style::default().fg(Palette::VALUE))),
2543 Line::from(Span::styled(" b/B Before/After toggle", Style::default().fg(Palette::VALUE))),
2544 Line::from(""),
2545 Line::from(Span::styled(" File Info / Preview", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2546 Line::from(Span::styled(" i Show full file info for selected file", Style::default().fg(Palette::VALUE))),
2547 Line::from(""),
2548 Line::from(Span::styled(" General", Style::default().fg(Palette::FOCUSED).add_modifier(Modifier::BOLD))),
2549 Line::from(Span::styled(" q Quit", Style::default().fg(Palette::VALUE))),
2550 Line::from(Span::styled(" ? Toggle this help", Style::default().fg(Palette::VALUE))),
2551 Line::from(Span::styled(" Esc Close popup/browser/help -> Quit", Style::default().fg(Palette::VALUE))),
2552 Line::from(""),
2553 Line::from(Span::styled(" Codec colors: [HW] green = hardware accelerated", Style::default().fg(Palette::HW_CODEC))),
2554 Line::from(Span::styled(" [SW] orange = software encoder", Style::default().fg(Palette::SW_CODEC))),
2555 Line::from(""),
2556 Line::from(Span::styled(" Logs: stored in app data directory, auto-cleaned after 7 days", Style::default().fg(Color::DarkGray))),
2557 Line::from(Span::styled(" ↑/↓, PageUp/Dn, Scroll wheel Scroll this help", Style::default().fg(Color::DarkGray))),
2558 ];
2559
2560 let inner_h = popup_area.height.saturating_sub(2) as usize;
2561 let scroll = app.help_scroll as usize;
2562 let visible: Vec<Line> = all_lines.iter()
2563 .skip(scroll)
2564 .take(inner_h)
2565 .cloned()
2566 .collect();
2567
2568 let popup = Paragraph::new(visible)
2569 .block(
2570 Block::default()
2571 .title(format!(" Help ({}/{}) Esc to close ", scroll + 1, all_lines.len()))
2572 .borders(Borders::ALL)
2573 .border_style(Style::default().fg(Palette::POPUP_BORDER)),
2574 )
2575 .wrap(Wrap { trim: false });
2576 frame.render_widget(popup, popup_area);
2577}
2578
2579fn format_size(bytes: u64) -> String {
2584 const KB: u64 = 1024;
2585 const MB: u64 = 1024 * 1024;
2586 const GB: u64 = 1024 * 1024 * 1024;
2587 if bytes >= GB {
2588 format!("{:.2} GB", bytes as f64 / GB as f64)
2589 } else if bytes >= MB {
2590 format!("{:.2} MB", bytes as f64 / MB as f64)
2591 } else if bytes >= KB {
2592 format!("{:.2} KB", bytes as f64 / KB as f64)
2593 } else {
2594 format!("{} B", bytes)
2595 }
2596}
2597
2598fn format_duration(seconds: f64) -> String {
2599 if seconds <= 0.0 {
2600 return "0:00".to_string();
2601 }
2602 let total_secs = seconds as u64;
2603 let hours = total_secs / 3600;
2604 let minutes = (total_secs % 3600) / 60;
2605 let secs = total_secs % 60;
2606 if hours > 0 {
2607 format!("{}:{:02}:{:02}", hours, minutes, secs)
2608 } else {
2609 format!("{}:{:02}", minutes, secs)
2610 }
2611}
2612
2613fn format_exposure_time(value: f64) -> String {
2614 if value <= 0.0 {
2615 return "Unknown".to_string();
2616 }
2617 let denominator = (1.0 / value).round() as u64;
2618 if denominator > 0 && denominator <= 10000 {
2619 format!("1/{}s", denominator)
2620 } else {
2621 format!("{:.2}s", value)
2622 }
2623}
2624
2625fn format_capture_date(raw: &str) -> String {
2626 let raw = raw.trim();
2627 if raw.len() >= 19 {
2628 let date_part = &raw[..10];
2629 let time_part = &raw[11..19];
2630 let tz_part = raw[19..].trim();
2631 let mut result = format!("{} {}", date_part, time_part);
2632 if !tz_part.is_empty() {
2633 result.push_str(tz_part);
2634 }
2635 return result;
2636 }
2637 raw.to_string()
2638}
2639
2640fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
2641 let popup_layout = Layout::default()
2642 .direction(Direction::Vertical)
2643 .constraints([
2644 Constraint::Percentage((100 - percent_y) / 2),
2645 Constraint::Percentage(percent_y),
2646 Constraint::Percentage((100 - percent_y) / 2),
2647 ])
2648 .split(area);
2649 Layout::default()
2650 .direction(Direction::Horizontal)
2651 .constraints([
2652 Constraint::Percentage((100 - percent_x) / 2),
2653 Constraint::Percentage(percent_x),
2654 Constraint::Percentage((100 - percent_x) / 2),
2655 ])
2656 .split(popup_layout[1])[1]
2657}
2658
2659fn render_preset_picker(frame: &mut Frame, area: Rect, app: &App) {
2664 let popup = centered_rect(70, 70, area);
2665 frame.render_widget(Clear, popup);
2666
2667 let total = app.presets.len();
2668 let title = if total == 0 {
2669 " Presets (none saved — press p in Export Settings to save current) ".to_string()
2670 } else {
2671 format!(" Presets ({}) — Enter applies · Delete removes · Esc closes ", total)
2672 };
2673
2674 let mut lines: Vec<Line> = Vec::new();
2675 if total == 0 {
2676 lines.push(Line::from(Span::styled(
2677 " No presets yet.",
2678 Style::default().fg(Palette::LABEL),
2679 )));
2680 lines.push(Line::from(Span::styled(
2681 " Focus the Export Settings panel and press [p] to save the current configuration.",
2682 Style::default().fg(Palette::LABEL),
2683 )));
2684 lines.push(Line::from(""));
2685 } else {
2686 for (i, p) in app.presets.iter().enumerate() {
2687 let is_sel = i == app.preset_picker.index;
2688 let marker = if is_sel { "> " } else { " " };
2689 let active = app.active_preset.as_deref() == Some(p.name.as_str());
2690 let synced = app.current_matches_preset(&p.name);
2691 let dot = if active && synced { "●" } else if active { "○" } else { " " };
2692 let summary = format!(
2693 "{} · {} · {}",
2694 p.codec_family.name(),
2695 p.color_space.name(),
2696 p.transfer_function.name()
2697 );
2698 let rate = p.rate_control.name();
2699 let name_style = if is_sel {
2700 Style::default()
2701 .fg(Palette::FOCUSED)
2702 .add_modifier(Modifier::BOLD)
2703 .bg(Palette::HIGHLIGHT_BG)
2704 } else {
2705 Style::default().fg(Palette::VALUE).add_modifier(Modifier::BOLD)
2706 };
2707 let meta_style = if is_sel {
2708 Style::default().fg(Palette::FOCUSED).bg(Palette::HIGHLIGHT_BG)
2709 } else {
2710 Style::default().fg(Palette::LABEL)
2711 };
2712 lines.push(Line::from(vec![
2713 Span::styled(format!("{}{} ", marker, dot), name_style),
2714 Span::styled(format!("{:<20}", truncate(&p.name, 20)), name_style),
2715 Span::styled(format!("{:<40}", truncate(&summary, 40)), meta_style),
2716 Span::styled(truncate(&rate, 18), meta_style),
2717 ]));
2718 }
2719 lines.push(Line::from(""));
2720 if let Some(p) = app.presets.get(app.preset_picker.index) {
2721 lines.push(Line::from(vec![
2722 Span::styled(" Codec: ", Style::default().fg(Palette::LABEL)),
2723 Span::styled(p.codec_family.name(), Style::default().fg(Palette::VALUE)),
2724 ]));
2725 lines.push(Line::from(vec![
2726 Span::styled(" Gamut: ", Style::default().fg(Palette::LABEL)),
2727 Span::styled(p.color_space.name(), Style::default().fg(Palette::VALUE)),
2728 ]));
2729 lines.push(Line::from(vec![
2730 Span::styled(" Trans: ", Style::default().fg(Palette::LABEL)),
2731 Span::styled(p.transfer_function.name(), Style::default().fg(Palette::VALUE)),
2732 ]));
2733 lines.push(Line::from(vec![
2734 Span::styled(" Rate: ", Style::default().fg(Palette::LABEL)),
2735 Span::styled(p.rate_control.name(), Style::default().fg(Palette::VALUE)),
2736 ]));
2737 if let Some(folder) = &p.export_folder {
2738 let disp = folder.display().to_string();
2739 let trimmed = if disp.len() > 60 {
2740 format!("…{}", &disp[disp.len().saturating_sub(59)..])
2741 } else {
2742 disp
2743 };
2744 lines.push(Line::from(vec![
2745 Span::styled(" Out: ", Style::default().fg(Palette::LABEL)),
2746 Span::styled(trimmed, Style::default().fg(Palette::VALUE)),
2747 ]));
2748 }
2749 }
2750 }
2751
2752 lines.push(Line::from(""));
2753 if let Some(ref msg) = app.preset_picker.message {
2754 lines.push(Line::from(Span::styled(
2755 format!(" {}", msg),
2756 Style::default().fg(Palette::SUCCESS),
2757 )));
2758 } else {
2759 lines.push(Line::from(Span::styled(
2760 " ↑/↓ navigate · Enter apply · Delete remove · Esc close",
2761 Style::default().fg(Palette::LABEL),
2762 )));
2763 }
2764
2765 let paragraph = Paragraph::new(lines)
2766 .block(
2767 Block::default()
2768 .title(title)
2769 .borders(Borders::ALL)
2770 .border_style(Style::default().fg(Palette::BORDER_FOCUSED))
2771 .title_style(Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD)),
2772 )
2773 .wrap(Wrap { trim: false });
2774 frame.render_widget(paragraph, popup);
2775}
2776
2777fn render_preset_naming(frame: &mut Frame, area: Rect, app: &App) {
2778 let popup = centered_rect(60, 25, area);
2779 frame.render_widget(Clear, popup);
2780
2781 let naming = app.preset_naming.as_ref().expect("naming state set");
2782 let display_name = if naming.name.is_empty() { " ".to_string() } else { naming.name.clone() };
2783
2784 let lines = vec![
2785 Line::from(Span::styled(" Save current export settings as preset", Style::default().fg(Palette::POPUP_TITLE).add_modifier(Modifier::BOLD))),
2786 Line::from(""),
2787 Line::from(Span::styled(" Name:", Style::default().fg(Palette::LABEL))),
2788 Line::from(Span::styled(
2789 format!(" > {}_", display_name),
2790 Style::default().fg(Palette::VALUE).add_modifier(Modifier::BOLD),
2791 )),
2792 Line::from(""),
2793 Line::from(Span::styled(
2794 " Summary (saved into preset):",
2795 Style::default().fg(Palette::LABEL),
2796 )),
2797 Line::from(Span::styled(
2798 format!(" {} · {} · {} · {}",
2799 app.export_codec_family.name(),
2800 app.export_color_space.name(),
2801 app.export_transfer_function.name(),
2802 app.active_rate_control.name(),
2803 ),
2804 Style::default().fg(Palette::VALUE),
2805 )),
2806 Line::from(""),
2807 Line::from(Span::styled(
2808 " Enter to save · Esc to cancel",
2809 Style::default().fg(Palette::LABEL),
2810 )),
2811 ];
2812
2813 let paragraph = Paragraph::new(lines)
2814 .block(
2815 Block::default()
2816 .title(" Save Preset ")
2817 .borders(Borders::ALL)
2818 .border_style(Style::default().fg(Palette::BORDER_FOCUSED)),
2819 )
2820 .wrap(Wrap { trim: false });
2821 frame.render_widget(paragraph, popup);
2822}
2823
2824fn truncate(s: &str, max: usize) -> String {
2825 if s.chars().count() <= max {
2826 s.to_string()
2827 } else if max <= 1 {
2828 "…".to_string()
2829 } else {
2830 let mut out: String = s.chars().take(max - 1).collect();
2831 out.push('…');
2832 out
2833 }
2834}