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