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