Skip to main content

notalterra/
tui.rs

1//! Modern terminal UI built on ratatui + crossterm.
2//!
3//! Design principles:
4//! - Dashboard layout: header bar, main panel, status line
5//! - Keyboard-first: arrow keys + Enter/Esc, no mouse dependency
6//!
7//! Terminal UI rendering for NotAlterra.
8//!
9//! Uses ratatui + crossterm to draw menu screens, picker lists, dialogs,
10//! metadata inspectors, and the animated whale separator.  All rendering
11//! is stateless — callers pass in an [`AppState`] snapshot.
12
13use ratatui::{
14    layout::{Alignment, Constraint, Direction, Layout, Rect},
15    style::{Color, Modifier, Style},
16    text::{Line, Span},
17    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
18    Frame,
19};
20use std::time::Instant;
21
22// ── app state ──────────────────────────────────────────────────────────────
23
24/// Global application state passed through every frame.
25pub struct AppState {
26    /// Terminal dimensions (updated on Resize events)
27    pub cols: u16,
28    pub rows: u16,
29    /// Current save-folder path (for the header bar)
30    pub save_path: Option<String>,
31    /// Number of live .sav files in the current folder
32    pub live_save_count: usize,
33    /// Number of .bak backup files
34    pub backup_count: usize,
35    /// Whether a .ini backup exists
36    pub has_ini_backup: bool,
37    /// Context-specific path shown on the right side of the header bar.
38    /// When `None`, falls back to `save_path`.
39    pub context_path: Option<String>,
40    /// Version string for the header
41    pub version: String,
42    /// Last operation result (for the status bar)
43    pub status_message: Option<String>,
44    pub status_style: StatusStyle,
45    /// Spinner state
46    pub spinner_active: bool,
47    pub spinner_start: Option<Instant>,
48    pub whale_start: Instant,
49}
50
51#[derive(Clone, Copy, PartialEq)]
52pub enum StatusStyle {
53    Info,
54    Success,
55    Warning,
56    Error,
57    Neutral,
58}
59
60impl Default for AppState {
61    fn default() -> Self {
62        Self {
63            cols: 80,
64            rows: 24,
65            save_path: None,
66            live_save_count: 0,
67            backup_count: 0,
68            has_ini_backup: false,
69            context_path: None,
70            version: String::new(),
71            status_message: None,
72            status_style: StatusStyle::Neutral,
73            spinner_active: false,
74            spinner_start: None,
75            whale_start: Instant::now(),
76        }
77    }
78}
79
80// ── public rendering entry points ──────────────────────────────────────────
81
82/// Draw the main menu.
83pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
84    let items: Vec<&str> = vec![
85        " Set Subnautica 2 location",
86        " Recover save file",
87        "",
88        " Set backup location",
89        " Create full backup",
90        " Restore full backup",
91        "",
92        " Manage UE5 Config (.ini) files",
93        "",
94        " View disclaimer",
95        " Exit",
96    ];
97    let descs: Vec<&str> = vec![
98        "Enter your Subnautica 2 save folder path (paste supported)",
99        "Restore a save file from a backup",
100        "",
101        "Choose where backup archives are stored (default: next to the binary)",
102        "Copy the savegame files to NotAlterra_Backups",
103        "Restore a full backup from NotAlterra_Backups",
104        "",
105        "Backup, restore, or delete .ini files in Config/Windows",
106        "",
107        "Re-read the disclaimer and terms of use",
108        "Close NotAlterra",
109    ];
110    let chunks = standard_layout(f.area(), items.len());
111
112    draw_header(f, chunks[0], app);
113    draw_status_dashboard(f, chunks[1], app);
114
115    let prompt = "↑/↓ navigate  Enter select";
116    draw_select_list(f, chunks[2], &items, &descs, prompt, state);
117
118    draw_status_bar(f, chunks[4], app);
119}
120
121/// Draw the disclaimer popup with full warning text.
122pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool) {
123    // Whale at bottom row
124    let bar = Rect {
125        x: 0,
126        y: f.area().height.saturating_sub(1),
127        width: f.area().width,
128        height: 1,
129    };
130    draw_whale_separator(f, bar, app);
131    let popup_w = 60.min(f.area().width.saturating_sub(4));
132    let popup_h = 18.min(f.area().height.saturating_sub(4));
133    let area = centered_rect_size(popup_w, popup_h, f.area());
134    f.render_widget(Clear, area);
135
136    let block = Block::default()
137        .borders(Borders::ALL)
138        .border_type(BorderType::Plain)
139        .border_style(Style::default().fg(Color::Yellow));
140    f.render_widget(block, area);
141
142    let inner = inner(area, 2, 1);
143
144    let lines = vec![
145        Line::from(Span::styled(
146            "DISCLAIMER",
147            Style::default()
148                .fg(Color::Yellow)
149                .add_modifier(Modifier::BOLD),
150        )),
151        Line::from(""),
152        Line::from(Span::styled(
153            "This tool was created using an AI Agent. While",
154            Style::default().fg(Color::White),
155        )),
156        Line::from(Span::styled(
157            "every effort has been made to ensure it works",
158            Style::default().fg(Color::White),
159        )),
160        Line::from(Span::styled(
161            "correctly, you should review the code and test",
162            Style::default().fg(Color::White),
163        )),
164        Line::from(Span::styled(
165            "on a backup before using it on live save files.",
166            Style::default().fg(Color::White),
167        )),
168        Line::from(""),
169        Line::from(Span::styled(
170            "NotAlterra is not affiliated with Unknown Worlds",
171            Style::default().fg(Color::DarkGray),
172        )),
173        Line::from(Span::styled(
174            "Entertainment or KRAFTON. Use at your own risk.",
175            Style::default().fg(Color::DarkGray),
176        )),
177        Line::from(""),
178        Line::from(Span::styled(
179            "The author is NOT responsible for any data loss.",
180            Style::default()
181                .fg(Color::White)
182                .add_modifier(Modifier::BOLD),
183        )),
184    ];
185    f.render_widget(
186        Paragraph::new(lines).alignment(Alignment::Center),
187        Rect {
188            height: 11,
189            ..inner
190        },
191    );
192
193    let yes_style = if selected_yes {
194        Style::default()
195            .fg(Color::Black)
196            .bg(Color::Green)
197            .add_modifier(Modifier::BOLD)
198    } else {
199        Style::default().fg(Color::Green)
200    };
201    let no_style = if !selected_yes {
202        Style::default()
203            .fg(Color::Black)
204            .bg(Color::Red)
205            .add_modifier(Modifier::BOLD)
206    } else {
207        Style::default().fg(Color::Red)
208    };
209    let buttons = Line::from(vec![
210        Span::styled("[ Accept ]", yes_style),
211        Span::raw("    "),
212        Span::styled("[ Decline ]", no_style),
213    ]);
214    f.render_widget(
215        Paragraph::new(buttons).alignment(Alignment::Center),
216        Rect {
217            y: inner.y + 12,
218            height: 1,
219            ..inner
220        },
221    );
222}
223
224/// Draw a simple confirmation popup with \[ Yes \] \[ No \] buttons.
225pub fn draw_confirm_popup(
226    f: &mut Frame,
227    app: &AppState,
228    title: &str,
229    details: &[(&str, &str)],
230    selected_yes: bool,
231) {
232    let max_w = details
233        .iter()
234        .map(|(k, v)| k.len() + v.len() + 4)
235        .max()
236        .unwrap_or(20)
237        .max(30) as u16;
238    let popup_w = (max_w + 4).min(f.area().width.saturating_sub(4));
239    let popup_h = (details.len() as u16 + 6).min(f.area().height.saturating_sub(4));
240    let area = centered_rect_size(popup_w, popup_h, f.area());
241    f.render_widget(Clear, area);
242
243    let block = Block::default()
244        .borders(Borders::ALL)
245        .border_type(BorderType::Plain)
246        .border_style(Style::default().fg(Color::Yellow));
247    f.render_widget(block, area);
248
249    let inner = inner(area, 2, 1);
250
251    // Title
252    f.render_widget(
253        Paragraph::new(Span::styled(
254            title,
255            Style::default()
256                .fg(Color::Yellow)
257                .add_modifier(Modifier::BOLD),
258        ))
259        .alignment(Alignment::Center),
260        Rect { height: 1, ..inner },
261    );
262
263    // Details
264    let detail_lines: Vec<Line> = details
265        .iter()
266        .map(|(k, v)| {
267            let icon = if k.starts_with('⚠') {
268                Color::Yellow
269            } else {
270                Color::Gray
271            };
272            Line::from(vec![
273                Span::styled(format!("{k}: "), Style::default().fg(icon)),
274                Span::styled(*v, Style::default()),
275            ])
276        })
277        .collect();
278    f.render_widget(
279        Paragraph::new(detail_lines),
280        Rect {
281            y: inner.y + 2,
282            height: details.len() as u16,
283            ..inner
284        },
285    );
286
287    // Yes / No buttons
288    let yes_style = if selected_yes {
289        Style::default()
290            .fg(Color::Black)
291            .bg(Color::Green)
292            .add_modifier(Modifier::BOLD)
293    } else {
294        Style::default().fg(Color::Green)
295    };
296    let no_style = if !selected_yes {
297        Style::default()
298            .fg(Color::Black)
299            .bg(Color::Red)
300            .add_modifier(Modifier::BOLD)
301    } else {
302        Style::default().fg(Color::Red)
303    };
304    let buttons = Line::from(vec![
305        Span::styled("[ Yes ]", yes_style),
306        Span::raw("    "),
307        Span::styled("[ No ]", no_style),
308    ]);
309    f.render_widget(
310        Paragraph::new(buttons).alignment(Alignment::Center),
311        Rect {
312            y: inner.y + inner.height.saturating_sub(1),
313            height: 1,
314            ..inner
315        },
316    );
317
318    // Whale
319    let bar = Rect {
320        x: 0,
321        y: f.area().height.saturating_sub(1),
322        width: f.area().width,
323        height: 1,
324    };
325    draw_whale_separator(f, bar, app);
326}
327
328/// Render an informational dialog with a plain-text message and OK button.
329/// Auto-sizes to fit content.  Title is displayed in cyan, message in gray,
330/// whale separator at the bottom.  Press Enter or Space to dismiss.
331pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
332    let content_w = message
333        .lines()
334        .map(|l| l.len())
335        .max()
336        .unwrap_or(20)
337        .max(title.len()) as u16
338        + 10;
339    let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
340    let popup_h = (message.lines().count() as u16 + 7).min(f.area().height.saturating_sub(4));
341    let area = centered_rect_size(popup_w, popup_h, f.area());
342    f.render_widget(Clear, area);
343    let block = Block::default()
344        .borders(Borders::ALL)
345        .border_type(BorderType::Plain)
346        .border_style(Style::default().fg(Color::Cyan));
347    f.render_widget(block, area);
348    let inner = inner(area, 2, 1);
349    f.render_widget(
350        Paragraph::new(Span::styled(
351            title,
352            Style::default()
353                .fg(Color::Cyan)
354                .add_modifier(Modifier::BOLD),
355        ))
356        .alignment(Alignment::Center),
357        Rect { height: 1, ..inner },
358    );
359    let msg_h = message.lines().count() as u16;
360    f.render_widget(
361        Paragraph::new(message.to_string())
362            .style(Style::default().fg(Color::Gray))
363            .alignment(Alignment::Left),
364        Rect {
365            x: inner.x + 2,
366            y: inner.y + 2,
367            width: inner.width.saturating_sub(4),
368            height: msg_h,
369        },
370    );
371    let ok = Span::styled(
372        "[ OK ]",
373        Style::default()
374            .fg(Color::Black)
375            .bg(Color::Cyan)
376            .add_modifier(Modifier::BOLD),
377    );
378    f.render_widget(
379        Paragraph::new(ok).alignment(Alignment::Center),
380        Rect {
381            y: inner.y + inner.height.saturating_sub(2),
382            height: 1,
383            ..inner
384        },
385    );
386
387    // Whale
388    let bar = Rect {
389        x: 0,
390        y: f.area().height.saturating_sub(1),
391        width: f.area().width,
392        height: 1,
393    };
394    draw_whale_separator(f, bar, app);
395}
396
397/// Render a non-interactive info dialog — no buttons, renders once, caller
398/// is expected to return to the event loop (the dialog stays visible until
399/// the next `terminal.draw()` replaces it).
400pub fn draw_info_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
401    let content_w = message
402        .lines()
403        .map(|l| l.len())
404        .max()
405        .unwrap_or(20)
406        .max(title.len()) as u16
407        + 10;
408    let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
409    let popup_h = (message.lines().count() as u16 + 6).min(f.area().height.saturating_sub(4));
410    let area = centered_rect_size(popup_w, popup_h, f.area());
411    f.render_widget(Clear, area);
412    let block = Block::default()
413        .borders(Borders::ALL)
414        .border_type(BorderType::Plain)
415        .border_style(Style::default().fg(Color::Cyan));
416    f.render_widget(block, area);
417    let inner = inner(area, 2, 1);
418    f.render_widget(
419        Paragraph::new(Span::styled(
420            title,
421            Style::default()
422                .fg(Color::Cyan)
423                .add_modifier(Modifier::BOLD),
424        ))
425        .alignment(Alignment::Center),
426        Rect { height: 1, ..inner },
427    );
428    let msg_h = message.lines().count() as u16;
429    f.render_widget(
430        Paragraph::new(message.to_string())
431            .style(Style::default().fg(Color::Gray))
432            .alignment(Alignment::Left),
433        Rect {
434            x: inner.x + 2,
435            y: inner.y + 2,
436            width: inner.width.saturating_sub(4),
437            height: msg_h,
438        },
439    );
440    // No button — this is informational only
441
442    // Whale
443    let bar = Rect {
444        x: 0,
445        y: f.area().height.saturating_sub(1),
446        width: f.area().width,
447        height: 1,
448    };
449    draw_whale_separator(f, bar, app);
450}
451
452/// Render a dialog with styled content lines.  Supports inline formatting
453/// (colors, bold) via [`Line`] slices.  Use for metadata displays, help
454/// text, or any content that needs per-span styling.
455pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines: &[Line]) {
456    let content_w = lines
457        .iter()
458        .map(|l| l.width() as u16)
459        .max()
460        .unwrap_or(20)
461        .max(title.len() as u16)
462        + 10;
463    let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
464    let popup_h = (lines.len() as u16 + 7).min(f.area().height.saturating_sub(4));
465    let area = centered_rect_size(popup_w, popup_h, f.area());
466    f.render_widget(Clear, area);
467    let block = Block::default()
468        .borders(Borders::ALL)
469        .border_type(BorderType::Plain)
470        .border_style(Style::default().fg(Color::Cyan));
471    f.render_widget(block, area);
472    let inner = inner(area, 2, 1);
473    f.render_widget(
474        Paragraph::new(Span::styled(
475            title,
476            Style::default()
477                .fg(Color::Cyan)
478                .add_modifier(Modifier::BOLD),
479        ))
480        .alignment(Alignment::Center),
481        Rect { height: 1, ..inner },
482    );
483    f.render_widget(
484        Paragraph::new(lines.to_vec())
485            .style(Style::default())
486            .alignment(Alignment::Left),
487        Rect {
488            x: inner.x + 2,
489            y: inner.y + 2,
490            width: inner.width.saturating_sub(4),
491            height: lines.len() as u16,
492        },
493    );
494    let ok = Span::styled(
495        "[ OK ]",
496        Style::default()
497            .fg(Color::Black)
498            .bg(Color::Cyan)
499            .add_modifier(Modifier::BOLD),
500    );
501    f.render_widget(
502        Paragraph::new(ok).alignment(Alignment::Center),
503        Rect {
504            y: inner.y + inner.height.saturating_sub(2),
505            height: 1,
506            ..inner
507        },
508    );
509
510    // Whale
511    let bar = Rect {
512        x: 0,
513        y: f.area().height.saturating_sub(1),
514        width: f.area().width,
515        height: 1,
516    };
517    draw_whale_separator(f, bar, app);
518}
519
520/// Return a rectangle centered in `r` by the given width and height percentages.
521/// Shrink a rectangle to the given absolute width and height, centered.
522/// Return a rectangle centered in `r` by the given width and height percentages.
523fn centered_rect_size(w: u16, h: u16, r: Rect) -> Rect {
524    let popup = Layout::default()
525        .direction(Direction::Vertical)
526        .constraints([
527            Constraint::Length((r.height.saturating_sub(h)) / 2),
528            Constraint::Length(h),
529            Constraint::Length((r.height.saturating_sub(h)) / 2),
530        ])
531        .split(r);
532    Layout::default()
533        .direction(Direction::Horizontal)
534        .constraints([
535            Constraint::Length((r.width.saturating_sub(w)) / 2),
536            Constraint::Length(w),
537            Constraint::Length((r.width.saturating_sub(w)) / 2),
538        ])
539        .split(popup[1])[1]
540}
541
542/// Draw a sub-menu (e.g. Config management).
543pub fn draw_sub_menu(
544    f: &mut Frame,
545    app: &AppState,
546    title: &str,
547    items: &[&str],
548    descs: &[&str],
549    state: &mut ListState,
550) {
551    let chunks = standard_layout(f.area(), items.len());
552
553    draw_header(f, chunks[0], app);
554
555    let title_p = Paragraph::new(Span::styled(
556        format!("   {title}"),
557        Style::default()
558            .fg(Color::Cyan)
559            .add_modifier(Modifier::BOLD),
560    ));
561    f.render_widget(title_p, chunks[1]);
562
563    draw_select_list(
564        f,
565        chunks[2],
566        items,
567        descs,
568        "↑/↓ navigate  Enter select  Esc back",
569        state,
570    );
571    draw_status_bar(f, chunks[4], app);
572}
573
574/// Draw a full-screen text display with a "press any key" prompt at the
575/// bottom.  Used for status messages during long operations (scanning,
576/// backing up) and for displaying scan results.
577pub fn draw_text_screen(f: &mut Frame, app: &AppState, lines: &[Line], prompt: &str) {
578    let chunks = standard_layout(f.area(), lines.len());
579    draw_header(f, chunks[0], app);
580
581    f.render_widget(Paragraph::new(lines.to_vec()), chunks[2]);
582
583    let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
584        .alignment(Alignment::Center);
585    f.render_widget(prompt_p, chunks[4]);
586}
587
588/// Draw a file/folder picker list.
589/// `pinned_header` renders items\[0\] as a fixed header above the scrollable list.
590pub fn draw_picker(
591    f: &mut Frame,
592    app: &AppState,
593    items: &[&str],
594    descs: &[&str],
595    state: &mut ListState,
596    pinned_header: bool,
597) {
598    draw_picker_with_info(f, app, items, descs, state, pinned_header);
599}
600
601/// Draw a file/folder picker list with an extra selected-item info line
602/// (e.g. showing the full filename of the highlighted .bak file).
603/// `pinned_header` renders items\[0\] as a fixed header above the scrollable list.
604pub fn draw_picker_with_info(
605    f: &mut Frame,
606    app: &AppState,
607    items: &[&str],
608    descs: &[&str],
609    state: &mut ListState,
610    pinned_header: bool,
611) {
612    let chunks = standard_layout(f.area(), items.len());
613    draw_header(f, chunks[0], app);
614
615    let prompt = "↑/↓ navigate | Enter select | Esc cancel";
616    draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, pinned_header);
617    draw_status_bar(f, chunks[4], app);
618}
619
620// ── internal drawing helpers ───────────────────────────────────────────────
621
622fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
623    Layout::default()
624        .direction(Direction::Vertical)
625        .constraints([
626            Constraint::Length(3), // header
627            Constraint::Length(2), // dashboard
628            Constraint::Min(1),    // menu (fills remaining)
629            Constraint::Length(1), // spacer
630            Constraint::Length(1), // status bar
631        ])
632        .split(area)
633        .to_vec()
634}
635
636/// Render the title bar with version information.
637fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
638    let header_block = Block::default()
639        .borders(Borders::BOTTOM)
640        .border_type(BorderType::Plain)
641        .border_style(Style::default().fg(Color::Cyan));
642
643    let chunks = Layout::default()
644        .direction(Direction::Horizontal)
645        .constraints([Constraint::Length(20), Constraint::Min(0)])
646        .split(inner(area, 1, 0));
647
648    let title_line = Line::from(vec![
649        Span::styled(
650            "NotAlterra",
651            Style::default()
652                .fg(Color::Cyan)
653                .add_modifier(Modifier::BOLD),
654        ),
655        Span::raw(" "),
656        Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)),
657    ]);
658    f.render_widget(Paragraph::new(title_line), chunks[0]);
659
660    // Header path priority:
661    //   1. context_path = Some("path")  → show that path
662    //   2. context_path = Some("")       → show nothing (blank/disclaimer/exit)
663    //   3. context_path = None           → fall back to save_path
664    let max_w = chunks[1].width.saturating_sub(2) as usize;
665    let path_line = match &app.context_path {
666        Some(p) if p.is_empty() => {
667            // Show nothing — blank line, disclaimer, or exit
668            Paragraph::new(Span::raw(""))
669        }
670        Some(p) => {
671            let display = truncate_path_tail(p, max_w);
672            Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
673                .alignment(Alignment::Right)
674        }
675        None => {
676            // Fall back to save_path
677            if let Some(ref save) = app.save_path {
678                let display = truncate_path_tail(save, max_w);
679                Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
680                    .alignment(Alignment::Right)
681            } else {
682                Paragraph::new(Span::styled(
683                    "no save folder selected",
684                    Style::default().fg(Color::DarkGray),
685                ))
686                .alignment(Alignment::Right)
687            }
688        }
689    };
690    f.render_widget(path_line, chunks[1]);
691    f.render_widget(header_block, area);
692}
693
694/// Render the status dashboard beneath the header.
695fn draw_status_dashboard(f: &mut Frame, area: Rect, app: &AppState) {
696    let live = Span::styled(
697        format!(
698            " Save{}: {} ",
699            if app.live_save_count == 1 { "" } else { "s" },
700            if app.save_path.is_some() {
701                app.live_save_count.to_string()
702            } else {
703                "—".into()
704            }
705        ),
706        Style::default().fg(Color::Green),
707    );
708    let bak = Span::styled(
709        format!(
710            " Backup{}: {} ",
711            if app.backup_count == 1 { "" } else { "s" },
712            app.backup_count
713        ),
714        Style::default().fg(Color::Yellow),
715    );
716    let ini = Span::styled(
717        format!(
718            " .ini backup: {} ",
719            if app.has_ini_backup { "yes" } else { "no" }
720        ),
721        Style::default().fg(if app.has_ini_backup {
722            Color::Green
723        } else {
724            Color::DarkGray
725        }),
726    );
727
728    let line = Line::from(vec![
729        Span::raw("  "),
730        live,
731        Span::raw("  "),
732        bak,
733        Span::raw("  "),
734        ini,
735    ]);
736
737    f.render_widget(Paragraph::new(line), area);
738}
739
740/// Render a scrollable picker list with description and prompt.
741#[allow(unused)]
742fn draw_select_list(
743    f: &mut Frame,
744    area: Rect,
745    items: &[&str],
746    descs: &[&str],
747    prompt: &str,
748    state: &mut ListState,
749) {
750    let list_area = Rect {
751        height: area.height.saturating_sub(1),
752        ..area
753    };
754
755    let list_items: Vec<ListItem> = items
756        .iter()
757        .map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
758        .collect();
759
760    let list = List::new(list_items)
761        .highlight_style(
762            Style::default()
763                .fg(Color::Yellow)
764                .add_modifier(Modifier::BOLD),
765        )
766        .highlight_symbol("► ")
767        .repeat_highlight_symbol(true);
768
769    f.render_stateful_widget(list, list_area, state);
770
771    // Description line for the highlighted item
772    let desc_idx = state
773        .selected()
774        .unwrap_or(0)
775        .min(descs.len().saturating_sub(1));
776    let desc = descs.get(desc_idx).copied().unwrap_or("");
777    let desc_line = Paragraph::new(Span::styled(
778        format!("  {desc}"),
779        Style::default().fg(Color::DarkGray),
780    ));
781
782    f.render_widget(
783        desc_line,
784        Rect {
785            x: area.x,
786            y: area.y + area.height.saturating_sub(1),
787            width: area.width,
788            height: 1,
789        },
790    );
791
792    // Prompt at bottom-right
793    let prompt_len = prompt.len() as u16;
794    if area.width > prompt_len + 2 {
795        let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
796            .alignment(Alignment::Right);
797        f.render_widget(
798            prompt_p,
799            Rect {
800                x: area.x,
801                y: area.y + area.height.saturating_sub(1),
802                width: area.width.saturating_sub(2),
803                height: 1,
804            },
805        );
806    }
807}
808
809/// Render a picker list with description and prompt.
810fn draw_select_list_with_info(
811    f: &mut Frame,
812    area: Rect,
813    items: &[&str],
814    descs: &[&str],
815    prompt: &str,
816    state: &mut ListState,
817    pinned_header: bool,
818) {
819    // If pinned_header is set, items[0] (header) and items[1] (blank spacer)
820    // render as fixed rows above the scrollable list. items[2..] form the list.
821    let (list_start_y, list_items_slice): (u16, &[&str]) = if pinned_header && !items.is_empty() {
822        let header_style = Style::default()
823            .fg(Color::Cyan)
824            .add_modifier(Modifier::BOLD);
825        f.render_widget(
826            Paragraph::new(Span::styled(items[0], header_style)),
827            Rect {
828                x: area.x,
829                y: area.y,
830                width: area.width,
831                height: 1,
832            },
833        );
834        // Leave items[1] (blank spacer) as visual gap — rendered as empty row
835        (area.y + 2, &items[2..])
836    } else {
837        (area.y, items)
838    };
839
840    let list_area = Rect {
841        y: list_start_y,
842        height: area.height.saturating_sub(2 + (list_start_y - area.y)),
843        ..area
844    };
845
846    // Offset: when pinned_header is true, the list widget only sees
847    // items[2..], but state.selected() is absolute to the full items array.
848    // Adjust the state so the list widget highlights the correct entry.
849    let header_offset = if pinned_header && !items.is_empty() {
850        2u16
851    } else {
852        0u16
853    };
854    let orig_selected = state.selected();
855    if header_offset > 0 {
856        if let Some(s) = orig_selected {
857            state.select(Some(s.saturating_sub(header_offset as usize)));
858        }
859    }
860
861    let list_items: Vec<ListItem> = list_items_slice
862        .iter()
863        .map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
864        .collect();
865
866    let list = List::new(list_items)
867        .highlight_style(
868            Style::default()
869                .fg(Color::Yellow)
870                .add_modifier(Modifier::BOLD),
871        )
872        .highlight_symbol("► ")
873        .repeat_highlight_symbol(true);
874
875    f.render_stateful_widget(list, list_area, state);
876
877    // Restore state to absolute indexing for the caller
878    if header_offset > 0 {
879        let s = state.selected().unwrap_or(0);
880        state.select(Some(s + header_offset as usize));
881    }
882
883    // Description line — use original (absolute) selection index
884    let base_y = area.y + area.height.saturating_sub(1);
885    let desc_idx = orig_selected
886        .unwrap_or(0)
887        .min(descs.len().saturating_sub(1));
888    let desc = descs.get(desc_idx).copied().unwrap_or("");
889    let desc_line = Paragraph::new(Span::styled(
890        format!("  {desc}"),
891        Style::default().fg(Color::DarkGray),
892    ));
893
894    f.render_widget(
895        desc_line,
896        Rect {
897            x: area.x,
898            y: base_y,
899            width: area.width,
900            height: 1,
901        },
902    );
903
904    let prompt_len = prompt.len() as u16;
905    if area.width > prompt_len + 2 {
906        let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
907            .alignment(Alignment::Right);
908        f.render_widget(
909            prompt_p,
910            Rect {
911                x: area.x,
912                y: base_y,
913                width: area.width.saturating_sub(2),
914                height: 1,
915            },
916        );
917    }
918}
919
920// ── pip-list renderer (for split-layout file picker) ─────────────────────
921
922/// Render a compact pip-list without description line or prompt.
923/// The pip (►) replaces the full-row background highlight.
924fn draw_select_list_pip(f: &mut Frame, area: Rect, items: &[&str], state: &mut ListState) {
925    if area.height < 2 || area.width < 10 {
926        return;
927    }
928
929    let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
930
931    let list_items: Vec<ListItem> = items
932        .iter()
933        .enumerate()
934        .map(|(i, item)| {
935            let style = if i == 0 {
936                // Header row — match right pane header color
937                Style::default()
938                    .fg(Color::Cyan)
939                    .add_modifier(Modifier::BOLD)
940            } else if i >= 2 {
941                // Data rows — match right pane value color
942                dim_val
943            } else {
944                Style::default()
945            };
946            ListItem::new(Span::raw(*item)).style(style)
947        })
948        .collect();
949
950    let list = List::new(list_items)
951        .highlight_style(
952            Style::default()
953                .fg(Color::Yellow)
954                .add_modifier(Modifier::BOLD),
955        )
956        .highlight_symbol("► ")
957        .repeat_highlight_symbol(true);
958
959    f.render_stateful_widget(list, area, state);
960}
961
962// ── right-pane metadata panel ────────────────────────────────────────────
963
964/// Render the right-hand metadata pane in the split file picker.
965/// Shows the filename header, a dim separator, then the provided content lines.
966/// When `meta_lines` is empty, shows a placeholder message.
967fn draw_right_pane(f: &mut Frame, area: Rect, filename: &str, meta_lines: &[Line]) {
968    if area.height < 3 || area.width < 10 {
969        return;
970    }
971
972    let dim = Style::default().fg(Color::Rgb(160, 160, 160));
973
974    // Filename header
975    let mut y = area.y;
976    let fname = if filename.len() as u16 > area.width.saturating_sub(2) {
977        format!(
978            "{}…",
979            &filename[..area.width.saturating_sub(3).max(1) as usize]
980        )
981    } else {
982        filename.to_string()
983    };
984    f.render_widget(
985        Paragraph::new(Span::styled(
986            &fname,
987            Style::default()
988                .fg(Color::Cyan)
989                .add_modifier(Modifier::BOLD),
990        )),
991        Rect {
992            x: area.x + 1,
993            y,
994            width: area.width.saturating_sub(2),
995            height: 1,
996        },
997    );
998    y += 2;
999
1000    if meta_lines.is_empty() {
1001        // Placeholder
1002        let msg = if filename.is_empty() {
1003            Span::styled("select a file", dim)
1004        } else {
1005            Span::styled("select to load metadata", dim)
1006        };
1007        f.render_widget(
1008            Paragraph::new(msg),
1009            Rect {
1010                x: area.x + 1,
1011                y,
1012                width: area.width.saturating_sub(2),
1013                height: 1,
1014            },
1015        );
1016        return;
1017    }
1018
1019    // Content lines
1020    let max_lines = area.height.saturating_sub(2) as usize;
1021    for (i, line) in meta_lines.iter().enumerate().take(max_lines) {
1022        f.render_widget(
1023            Paragraph::new(line.clone()),
1024            Rect {
1025                x: area.x + 1,
1026                y: y + i as u16,
1027                width: area.width.saturating_sub(2),
1028                height: 1,
1029            },
1030        );
1031    }
1032}
1033
1034// ── split-layout picker entry point ──────────────────────────────────────
1035
1036/// Draw the file picker with a horizontal split: pip-style file list on the
1037/// left, live metadata preview on the right.  Used by the .bak recover flow.
1038pub fn draw_picker_split(
1039    f: &mut Frame,
1040    app: &AppState,
1041    items: &[&str],
1042    _descs: &[&str],
1043    state: &mut ListState,
1044    selected_info: Option<&str>,
1045    meta_lines: &[Line],
1046) {
1047    let chunks = standard_layout(f.area(), items.len());
1048    draw_header(f, chunks[0], app);
1049
1050    let menu_area = chunks[2];
1051    let prompt = "↑/↓ navigate | Enter select | Esc cancel";
1052
1053    // Reserve bottom row of menu area for the prompt
1054    let prompt_y = menu_area.y + menu_area.height.saturating_sub(1);
1055    let content_area = Rect {
1056        height: menu_area.height.saturating_sub(1),
1057        ..menu_area
1058    };
1059
1060    // Split content into left (60%) and right (40%)
1061    let halves = Layout::default()
1062        .direction(Direction::Horizontal)
1063        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1064        .split(content_area);
1065
1066    // Left: pip list (full height of content area)
1067    draw_select_list_pip(f, halves[0], items, state);
1068
1069    // Right: metadata pane
1070    draw_right_pane(f, halves[1], selected_info.unwrap_or(""), meta_lines);
1071
1072    // Prompt at bottom-right of the full menu area
1073    let prompt_len = prompt.len() as u16;
1074    if menu_area.width > prompt_len + 2 {
1075        f.render_widget(
1076            Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
1077                .alignment(Alignment::Right),
1078            Rect {
1079                x: menu_area.x,
1080                y: prompt_y,
1081                width: menu_area.width.saturating_sub(2),
1082                height: 1,
1083            },
1084        );
1085    }
1086
1087    draw_status_bar(f, chunks[4], app);
1088}
1089
1090/// Render the status bar at the bottom of the screen.
1091fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
1092    draw_whale_separator(f, area, app);
1093}
1094
1095/// Draw the bottom status bar with an animated whale patrolling right-to-left.
1096/// The whale moves one position every 180ms.  After reaching the left edge,
1097/// it disappears for ~5.4s (30 cooldown ticks) before reappearing on the
1098/// right.  Two variants alternate every 400ms.
1099pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
1100    if area.width < 4 {
1101        return;
1102    }
1103    let elapsed = app.whale_start.elapsed().as_millis() as u64;
1104    let bar_w = area.width as u64;
1105    let speed_ms: u64 = 180;
1106    let cooldown_ticks: u64 = 30;
1107    let total = bar_w + cooldown_ticks;
1108    let t = (elapsed / speed_ms) % total;
1109    if t < bar_w {
1110        let x = bar_w - t - 1;
1111        let switch = ((elapsed / 400) * 7 + (elapsed / 600) * 13) % 2;
1112        let variants: &[&str] = &["🐋", "🐳"];
1113        let whale = variants[(switch % variants.len() as u64) as usize];
1114        f.render_widget(
1115            Paragraph::new(Span::styled(whale, Style::default().fg(Color::Cyan))),
1116            Rect {
1117                x: area.x + (x as u16).min(area.width.saturating_sub(4)),
1118                y: area.y,
1119                width: 4,
1120                height: 1,
1121            },
1122        );
1123    }
1124}
1125
1126// ── input dialog ─────────────────────────────────────────────────────────────
1127
1128/// State for the text-input dialog used to enter a save-folder path.
1129pub struct InputDialogState {
1130    pub input: String,
1131    pub cursor: usize,
1132    pub prompt: String,
1133    pub confirmed: bool,
1134    pub cancelled: bool,
1135}
1136
1137impl InputDialogState {
1138    pub fn new(prompt: impl Into<String>) -> Self {
1139        Self {
1140            input: String::new(),
1141            cursor: 0,
1142            prompt: prompt.into(),
1143            confirmed: false,
1144            cancelled: false,
1145        }
1146    }
1147
1148    pub fn reset(&mut self) {
1149        self.input.clear();
1150        self.cursor = 0;
1151        self.confirmed = false;
1152        self.cancelled = false;
1153    }
1154
1155    /// Insert a character at the cursor position.
1156    pub fn insert(&mut self, c: char) {
1157        self.input.insert(self.cursor, c);
1158        self.cursor += c.len_utf8();
1159    }
1160
1161    /// Insert a string at the cursor position (for paste).
1162    pub fn insert_str(&mut self, s: &str) {
1163        self.input.insert_str(self.cursor, s);
1164        self.cursor += s.len();
1165    }
1166
1167    /// Delete the character before the cursor (Backspace).
1168    pub fn backspace(&mut self) {
1169        if self.cursor > 0 {
1170            let prev = self.cursor.saturating_sub(1);
1171            self.input.remove(prev);
1172            self.cursor = prev;
1173        }
1174    }
1175
1176    /// Delete the character at the cursor (Delete).
1177    pub fn delete(&mut self) {
1178        if self.cursor < self.input.len() {
1179            self.input.remove(self.cursor);
1180        }
1181    }
1182
1183    /// Move cursor left by one grapheme boundary.
1184    pub fn cursor_left(&mut self) {
1185        if self.cursor > 0 {
1186            self.cursor = self.input[..self.cursor]
1187                .char_indices()
1188                .nth_back(0)
1189                .map(|(i, _c)| i)
1190                .unwrap_or(0);
1191        }
1192    }
1193
1194    /// Move cursor right by one grapheme boundary.
1195    pub fn cursor_right(&mut self) {
1196        if self.cursor < self.input.len() {
1197            self.cursor = self.input[self.cursor..]
1198                .char_indices()
1199                .nth(1)
1200                .map(|(i, _c)| self.cursor + i)
1201                .unwrap_or(self.input.len());
1202        }
1203    }
1204}
1205
1206/// Draw the text input dialog for entering a custom save-folder path.
1207///
1208/// Layout: title bar, prompt, input line with cursor indicator,
1209/// instruction line, and [ OK ] [ Cancel ] buttons.
1210pub fn draw_input_dialog(
1211    f: &mut Frame,
1212    _app: &AppState,
1213    state: &InputDialogState,
1214    ok_selected: bool,
1215) {
1216    let prompt_w = state.prompt.len() as u16 + 4;
1217    let input_display = &state.input;
1218    let display_w = input_display.len() + 4; // rough, but good enough for sizing
1219    let popup_w =
1220        (prompt_w.max(display_w as u16).max(40) + 4).min(f.area().width.saturating_sub(4));
1221    let popup_h = 10u16.min(f.area().height.saturating_sub(4));
1222    let area = centered_rect_size(popup_w, popup_h, f.area());
1223    f.render_widget(Clear, area);
1224
1225    let block = Block::default()
1226        .borders(Borders::ALL)
1227        .border_type(BorderType::Plain)
1228        .border_style(Style::default().fg(Color::Yellow));
1229    f.render_widget(block, area);
1230
1231    let inner = inner(area, 2, 1);
1232
1233    // Title
1234    f.render_widget(
1235        Paragraph::new(Span::styled(
1236            "Set Save Folder",
1237            Style::default()
1238                .fg(Color::Yellow)
1239                .add_modifier(Modifier::BOLD),
1240        )),
1241        Rect { height: 1, ..inner },
1242    );
1243
1244    // Prompt text
1245    f.render_widget(
1246        Paragraph::new(Span::styled(
1247            &state.prompt,
1248            Style::default().fg(Color::White),
1249        )),
1250        Rect {
1251            y: inner.y + 2,
1252            height: 1,
1253            width: inner.width,
1254            x: inner.x,
1255        },
1256    );
1257
1258    // Input line with cursor
1259    let cursor_visible = (std::time::Instant::now().elapsed().as_millis() / 500).is_multiple_of(2);
1260    let mut input_spans = vec![Span::styled("  ", Style::default())];
1261    // Show the text up to cursor
1262    let before = &state.input[..state.cursor.min(state.input.len())];
1263    let after = if state.cursor < state.input.len() {
1264        Some(&state.input[state.cursor..])
1265    } else {
1266        None
1267    };
1268    input_spans.push(Span::styled(before, Style::default().fg(Color::White)));
1269    if cursor_visible && !state.confirmed && !state.cancelled {
1270        input_spans.push(Span::styled("█", Style::default().fg(Color::Cyan)));
1271    }
1272    if let Some(a) = after {
1273        input_spans.push(Span::styled(a, Style::default().fg(Color::White)));
1274    }
1275    f.render_widget(
1276        Paragraph::new(Line::from(input_spans)),
1277        Rect {
1278            y: inner.y + 3,
1279            height: 1,
1280            width: inner.width,
1281            x: inner.x,
1282        },
1283    );
1284
1285    // Instruction line
1286    f.render_widget(
1287        Paragraph::new(Span::styled(
1288            "Type a path, then Tab to buttons  Enter to confirm  Esc to cancel",
1289            Style::default().fg(Color::DarkGray),
1290        )),
1291        Rect {
1292            y: inner.y + 5,
1293            height: 1,
1294            width: inner.width,
1295            x: inner.x,
1296        },
1297    );
1298
1299    // OK / Cancel buttons
1300    let ok_style = if ok_selected {
1301        Style::default()
1302            .fg(Color::Black)
1303            .bg(Color::Green)
1304            .add_modifier(Modifier::BOLD)
1305    } else {
1306        Style::default().fg(Color::Green)
1307    };
1308    let cancel_style = if !ok_selected {
1309        Style::default()
1310            .fg(Color::Black)
1311            .bg(Color::Red)
1312            .add_modifier(Modifier::BOLD)
1313    } else {
1314        Style::default().fg(Color::Red)
1315    };
1316    let buttons = Line::from(vec![
1317        Span::styled(" [ OK ] ", ok_style),
1318        Span::raw("  "),
1319        Span::styled(" [ Cancel ] ", cancel_style),
1320    ]);
1321    f.render_widget(
1322        Paragraph::new(buttons).alignment(Alignment::Center),
1323        Rect {
1324            y: inner.y + 6,
1325            height: 1,
1326            width: inner.width,
1327            x: inner.x,
1328        },
1329    );
1330
1331    // Whale
1332    let bar = Rect {
1333        x: 0,
1334        y: f.area().height.saturating_sub(1),
1335        width: f.area().width,
1336        height: 1,
1337    };
1338    draw_whale_separator(f, bar, _app);
1339}
1340
1341/// Truncate a filesystem path to show only the most specific directories.
1342/// Walks forward to the first path separator after the truncation point to
1343/// avoid splitting mid-component.
1344///
1345/// Examples:
1346/// - `C:\Users\user\AppData\...\SaveGames` → `…\AppData\...\SaveGames`
1347/// - A short path that fits `max_width` is returned unchanged.
1348fn truncate_path_tail(path: &str, max_width: usize) -> String {
1349    if path.len() <= max_width {
1350        return path.to_string();
1351    }
1352    let keep = max_width.saturating_sub(3);
1353    if keep == 0 {
1354        return "…".to_string();
1355    }
1356    let tail = &path[path.len().saturating_sub(keep)..];
1357    // Walk forward to a path separator so we don't split mid-component
1358    if let Some(sep_pos) = tail.find(&['\\', '/'][..]) {
1359        let start = path.len().saturating_sub(keep) + sep_pos;
1360        format!("…{}", &path[start..])
1361    } else {
1362        format!("…{}", tail)
1363    }
1364}
1365
1366/// Create a centered rectangle for modal overlays.
1367/// Return a rectangle centered in `r` by the given width and height percentages.
1368/// Return a rectangle centered in `r` by the given width and height percentages.
1369fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1370    let popup_layout = Layout::default()
1371        .direction(Direction::Vertical)
1372        .constraints([
1373            Constraint::Percentage((100 - percent_y) / 2),
1374            Constraint::Percentage(percent_y),
1375            Constraint::Percentage((100 - percent_y) / 2),
1376        ])
1377        .split(r);
1378
1379    Layout::default()
1380        .direction(Direction::Horizontal)
1381        .constraints([
1382            Constraint::Percentage((100 - percent_x) / 2),
1383            Constraint::Percentage(percent_x),
1384            Constraint::Percentage((100 - percent_x) / 2),
1385        ])
1386        .split(popup_layout[1])[1]
1387}
1388
1389/// Shrink a rect by a margin on all sides.
1390fn inner(rect: Rect, margin_x: u16, margin_y: u16) -> Rect {
1391    Rect {
1392        x: rect.x + margin_x,
1393        y: rect.y + margin_y,
1394        width: rect.width.saturating_sub(margin_x * 2),
1395        height: rect.height.saturating_sub(margin_y * 2),
1396    }
1397}