Skip to main content

mcraw_tui/
ui.rs

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