Skip to main content

mcraw_tui/
ui.rs

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