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