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