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