mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-18 01:09:20 +02:00
672 lines
24 KiB
Rust
672 lines
24 KiB
Rust
//! Modern terminal UI built on ratatui + crossterm.
|
|
//!
|
|
//! Design principles:
|
|
//! - Dashboard layout: header bar, main panel, status line
|
|
//! - Keyboard-first: arrow keys + Enter/Esc, no mouse dependency
|
|
//! Terminal UI rendering for NotAlterra.
|
|
//!
|
|
//! Uses ratatui + crossterm to draw menu screens, picker lists, dialogs,
|
|
//! metadata inspectors, and the animated whale separator. All rendering
|
|
//! is stateless — callers pass in an [`AppState`] snapshot.
|
|
|
|
use ratatui::{
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
text::{Line, Span},
|
|
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph},
|
|
Frame,
|
|
};
|
|
use std::time::Instant;
|
|
|
|
// ── app state ──────────────────────────────────────────────────────────────
|
|
|
|
/// Global application state passed through every frame.
|
|
pub struct AppState {
|
|
/// Terminal dimensions (updated on Resize events)
|
|
pub cols: u16,
|
|
pub rows: u16,
|
|
/// Current save-folder path (for the header bar)
|
|
pub save_path: Option<String>,
|
|
/// Number of live .sav files in the current folder
|
|
pub live_save_count: usize,
|
|
/// Number of .bak backup files
|
|
pub backup_count: usize,
|
|
/// Whether a .ini backup exists
|
|
pub has_ini_backup: bool,
|
|
/// Version string for the header
|
|
pub version: String,
|
|
/// Last operation result (for the status bar)
|
|
pub status_message: Option<String>,
|
|
pub status_style: StatusStyle,
|
|
/// Spinner state
|
|
pub spinner_active: bool,
|
|
pub spinner_start: Option<Instant>,
|
|
pub whale_start: Instant,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum StatusStyle {
|
|
Info,
|
|
Success,
|
|
Warning,
|
|
Error,
|
|
Neutral,
|
|
}
|
|
|
|
impl Default for AppState {
|
|
fn default() -> Self {
|
|
Self {
|
|
cols: 80,
|
|
rows: 24,
|
|
save_path: None,
|
|
live_save_count: 0,
|
|
backup_count: 0,
|
|
has_ini_backup: false,
|
|
version: String::new(),
|
|
status_message: None,
|
|
status_style: StatusStyle::Neutral,
|
|
spinner_active: false,
|
|
spinner_start: None,
|
|
whale_start: Instant::now(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── public rendering entry points ──────────────────────────────────────────
|
|
|
|
/// Draw the main menu. Hides the locate item when saves are already found.
|
|
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState, save_found: bool) {
|
|
let items: Vec<&str> = if save_found {
|
|
vec![
|
|
" Recover save file",
|
|
" Create full backup",
|
|
" Restore full backup",
|
|
" Inspect save files",
|
|
" Manage UE5 Config (.ini) files",
|
|
" View disclaimer",
|
|
" Exit",
|
|
]
|
|
} else {
|
|
vec![
|
|
" Locate Subnautica save files",
|
|
" Recover save file",
|
|
" Create full backup",
|
|
" Restore full backup",
|
|
" Inspect save files",
|
|
" Manage UE5 Config (.ini) files",
|
|
" View disclaimer",
|
|
" Exit",
|
|
]
|
|
};
|
|
let descs: Vec<&str> = if save_found {
|
|
vec![
|
|
"Restore a save file from a backup",
|
|
"Copy the savegame files to NotAlterra_Backups",
|
|
"Restore a full backup from NotAlterra_Backups",
|
|
"View detailed GVAS metadata for each save file",
|
|
"Backup, restore, or delete .ini files in Config/Windows",
|
|
"Re-read the disclaimer and terms of use",
|
|
"Close NotAlterra",
|
|
]
|
|
} else {
|
|
vec![
|
|
"Scan all drives for Subnautica 2 save folders",
|
|
"Restore a save file from a backup",
|
|
"Copy the savegame files to NotAlterra_Backups",
|
|
"Restore a full backup from NotAlterra_Backups",
|
|
"View detailed GVAS metadata for each save file",
|
|
"Backup, restore, or delete .ini files in Config/Windows",
|
|
"Re-read the disclaimer and terms of use",
|
|
"Close NotAlterra",
|
|
]
|
|
};
|
|
let chunks = standard_layout(f.area(), items.len());
|
|
|
|
draw_header(f, chunks[0], app);
|
|
draw_status_dashboard(f, chunks[1], app);
|
|
|
|
let prompt = "↑/↓ navigate Enter select";
|
|
draw_select_list(f, chunks[2], &items, &descs, prompt, state);
|
|
|
|
draw_status_bar(f, chunks[3], app);
|
|
}
|
|
|
|
/// Draw the disclaimer popup with full warning text.
|
|
pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool) {
|
|
// Whale at bottom row
|
|
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
|
|
draw_whale_separator(f, bar, app);
|
|
let popup_w = 60.min(f.area().width.saturating_sub(4));
|
|
let popup_h = 18.min(f.area().height.saturating_sub(4));
|
|
let area = centered_rect_size(popup_w, popup_h, f.area());
|
|
f.render_widget(Clear, area);
|
|
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Plain)
|
|
.border_style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(block, area);
|
|
|
|
let inner = inner(area, 2, 1);
|
|
|
|
let lines = vec![
|
|
Line::from(Span::styled("DISCLAIMER", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
|
|
Line::from(""),
|
|
Line::from(Span::styled("This tool was created using an AI Agent. While", Style::default().fg(Color::White))),
|
|
Line::from(Span::styled("every effort has been made to ensure it works", Style::default().fg(Color::White))),
|
|
Line::from(Span::styled("correctly, you should review the code and test", Style::default().fg(Color::White))),
|
|
Line::from(Span::styled("on a backup before using it on live save files.", Style::default().fg(Color::White))),
|
|
Line::from(""),
|
|
Line::from(Span::styled("NotAlterra is not affiliated with Unknown Worlds", Style::default().fg(Color::DarkGray))),
|
|
Line::from(Span::styled("Entertainment or KRAFTON. Use at your own risk.", Style::default().fg(Color::DarkGray))),
|
|
Line::from(""),
|
|
Line::from(Span::styled("The author is NOT responsible for any data loss.", Style::default().fg(Color::White).add_modifier(Modifier::BOLD))),
|
|
];
|
|
f.render_widget(Paragraph::new(lines).alignment(Alignment::Center), Rect { height: 11, ..inner });
|
|
|
|
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green) };
|
|
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Red) };
|
|
let buttons = Line::from(vec![Span::styled("[ Accept ]", yes_style), Span::raw(" "), Span::styled("[ Decline ]", no_style)]);
|
|
f.render_widget(Paragraph::new(buttons).alignment(Alignment::Center), Rect { y: inner.y + 12, height: 1, ..inner });
|
|
}
|
|
|
|
/// Draw a simple confirmation popup with [ Yes ] [ No ] buttons.
|
|
pub fn draw_confirm_popup(
|
|
f: &mut Frame,
|
|
app: &AppState,
|
|
title: &str,
|
|
details: &[(&str, &str)],
|
|
selected_yes: bool,
|
|
) {
|
|
let max_w = details.iter().map(|(k, v)| k.len() + v.len() + 4).max().unwrap_or(20).max(30) as u16;
|
|
let popup_w = (max_w + 4).min(f.area().width.saturating_sub(4));
|
|
let popup_h = (details.len() as u16 + 6).min(f.area().height.saturating_sub(4));
|
|
let area = centered_rect_size(popup_w, popup_h, f.area());
|
|
f.render_widget(Clear, area);
|
|
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.border_type(BorderType::Plain)
|
|
.border_style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(block, area);
|
|
|
|
let inner = inner(area, 2, 1);
|
|
|
|
// Title
|
|
f.render_widget(
|
|
Paragraph::new(Span::styled(title, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)))
|
|
.alignment(Alignment::Center),
|
|
Rect { height: 1, ..inner },
|
|
);
|
|
|
|
// Details
|
|
let detail_lines: Vec<Line> = details.iter().map(|(k, v)| {
|
|
let icon = if k.starts_with('⚠') { Color::Yellow } else { Color::Gray };
|
|
Line::from(vec![
|
|
Span::styled(format!("{k}: "), Style::default().fg(icon)),
|
|
Span::styled(*v, Style::default()),
|
|
])
|
|
}).collect();
|
|
f.render_widget(
|
|
Paragraph::new(detail_lines),
|
|
Rect { y: inner.y + 2, height: details.len() as u16, ..inner },
|
|
);
|
|
|
|
// Yes / No buttons
|
|
let yes_style = if selected_yes { Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) }
|
|
else { Style::default().fg(Color::Green) };
|
|
let no_style = if !selected_yes { Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) }
|
|
else { Style::default().fg(Color::Red) };
|
|
let buttons = Line::from(vec![
|
|
Span::styled("[ Yes ]", yes_style),
|
|
Span::raw(" "),
|
|
Span::styled("[ No ]", no_style),
|
|
]);
|
|
f.render_widget(
|
|
Paragraph::new(buttons).alignment(Alignment::Center),
|
|
Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner },
|
|
);
|
|
|
|
// Whale
|
|
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
|
|
draw_whale_separator(f, bar, app);
|
|
}
|
|
|
|
pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) {
|
|
let content_w = message.lines().map(|l| l.len()).max().unwrap_or(20).max(title.len()) as u16 + 10;
|
|
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
|
|
let popup_h = (message.lines().count() as u16 + 7).min(f.area().height.saturating_sub(4));
|
|
let area = centered_rect_size(popup_w, popup_h, f.area());
|
|
f.render_widget(Clear, area);
|
|
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
|
|
f.render_widget(block, area);
|
|
let inner = inner(area, 2, 1);
|
|
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
|
|
let msg_h = message.lines().count() as u16;
|
|
f.render_widget(Paragraph::new(message.to_string()).style(Style::default().fg(Color::Gray)).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: msg_h });
|
|
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
|
|
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
|
|
|
|
// Whale
|
|
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
|
|
draw_whale_separator(f, bar, app);
|
|
}
|
|
|
|
pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines: &[Line]) {
|
|
let content_w = lines.iter().map(|l| l.width() as u16).max().unwrap_or(20).max(title.len() as u16) + 10;
|
|
let popup_w = content_w.max(50).min(f.area().width.saturating_sub(4));
|
|
let popup_h = (lines.len() as u16 + 7).min(f.area().height.saturating_sub(4));
|
|
let area = centered_rect_size(popup_w, popup_h, f.area());
|
|
f.render_widget(Clear, area);
|
|
let block = Block::default().borders(Borders::ALL).border_type(BorderType::Plain).border_style(Style::default().fg(Color::Cyan));
|
|
f.render_widget(block, area);
|
|
let inner = inner(area, 2, 1);
|
|
f.render_widget(Paragraph::new(Span::styled(title, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))).alignment(Alignment::Center), Rect { height: 1, ..inner });
|
|
f.render_widget(Paragraph::new(lines.to_vec()).style(Style::default()).alignment(Alignment::Left), Rect { x: inner.x + 2, y: inner.y + 2, width: inner.width.saturating_sub(4), height: lines.len() as u16 });
|
|
let ok = Span::styled("[ OK ]", Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD));
|
|
f.render_widget(Paragraph::new(ok).alignment(Alignment::Center), Rect { y: inner.y + inner.height.saturating_sub(2), height: 1, ..inner });
|
|
|
|
// Whale
|
|
let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 };
|
|
draw_whale_separator(f, bar, app);
|
|
}
|
|
|
|
fn centered_rect_size(w: u16, h: u16, r: Rect) -> Rect {
|
|
let popup = Layout::default().direction(Direction::Vertical)
|
|
.constraints([Constraint::Length((r.height.saturating_sub(h))/2), Constraint::Length(h), Constraint::Length((r.height.saturating_sub(h))/2)])
|
|
.split(r);
|
|
Layout::default().direction(Direction::Horizontal)
|
|
.constraints([Constraint::Length((r.width.saturating_sub(w))/2), Constraint::Length(w), Constraint::Length((r.width.saturating_sub(w))/2)])
|
|
.split(popup[1])[1]
|
|
}
|
|
|
|
/// Draw a sub-menu (e.g. Config management).
|
|
pub fn draw_sub_menu(
|
|
f: &mut Frame,
|
|
app: &AppState,
|
|
title: &str,
|
|
items: &[&str],
|
|
descs: &[&str],
|
|
state: &mut ListState,
|
|
) {
|
|
let chunks = standard_layout(f.area(), items.len());
|
|
|
|
draw_header(f, chunks[0], app);
|
|
|
|
let title_p = Paragraph::new(Span::styled(
|
|
title,
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
f.render_widget(title_p, chunks[1]);
|
|
|
|
draw_select_list(f, chunks[2], items, descs, "↑/↓ navigate Enter select Esc back", state);
|
|
draw_status_bar(f, chunks[3], app);
|
|
}
|
|
|
|
/// Draw a simple text screen with a "press any key" prompt.
|
|
pub fn draw_text_screen(
|
|
f: &mut Frame,
|
|
app: &AppState,
|
|
lines: &[Line],
|
|
prompt: &str,
|
|
) {
|
|
let chunks = standard_layout(f.area(), lines.len());
|
|
draw_header(f, chunks[0], app);
|
|
|
|
f.render_widget(Paragraph::new(lines.to_vec()), chunks[2]);
|
|
|
|
let prompt_p = Paragraph::new(Span::styled(
|
|
prompt,
|
|
Style::default().fg(Color::DarkGray),
|
|
))
|
|
.alignment(Alignment::Center);
|
|
f.render_widget(prompt_p, chunks[3]);
|
|
}
|
|
|
|
/// Draw a file/folder picker list.
|
|
pub fn draw_picker(
|
|
f: &mut Frame,
|
|
app: &AppState,
|
|
items: &[&str],
|
|
descs: &[&str],
|
|
state: &mut ListState,
|
|
) {
|
|
draw_picker_with_info(f, app, items, descs, state, None);
|
|
}
|
|
|
|
/// Draw a file/folder picker list with an extra selected-item info line
|
|
/// (e.g. showing the full filename of the highlighted .bak file).
|
|
pub fn draw_picker_with_info(
|
|
f: &mut Frame,
|
|
app: &AppState,
|
|
items: &[&str],
|
|
descs: &[&str],
|
|
state: &mut ListState,
|
|
selected_info: Option<&str>,
|
|
) {
|
|
let chunks = standard_layout(f.area(), items.len());
|
|
draw_header(f, chunks[0], app);
|
|
|
|
let prompt = "↑/↓ navigate Enter select Esc cancel";
|
|
draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, selected_info);
|
|
draw_status_bar(f, chunks[3], app);
|
|
}
|
|
|
|
// ── internal drawing helpers ───────────────────────────────────────────────
|
|
|
|
fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
|
|
Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // header
|
|
Constraint::Length(2), // dashboard
|
|
Constraint::Min(1), // menu (fills remaining)
|
|
Constraint::Length(1), // status bar
|
|
])
|
|
.split(area).to_vec()
|
|
}
|
|
|
|
fn draw_header(f: &mut Frame, area: Rect, app: &AppState) {
|
|
let header_block = Block::default()
|
|
.borders(Borders::BOTTOM)
|
|
.border_type(BorderType::Plain)
|
|
.border_style(Style::default().fg(Color::Cyan));
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Length(20),
|
|
Constraint::Min(0),
|
|
])
|
|
.split(inner(area, 1, 0));
|
|
|
|
let title_line = Line::from(vec![
|
|
Span::styled("NotAlterra", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
|
Span::raw(" "),
|
|
Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)),
|
|
]);
|
|
f.render_widget(Paragraph::new(title_line), chunks[0]);
|
|
|
|
let path_line = if let Some(ref path) = app.save_path {
|
|
let max_w = chunks[1].width.saturating_sub(2) as usize;
|
|
let display = truncate_path_tail(path, max_w);
|
|
Paragraph::new(Span::styled(display, Style::default().fg(Color::Gray)))
|
|
.alignment(Alignment::Right)
|
|
} else {
|
|
Paragraph::new(Span::styled(
|
|
"no save folder selected",
|
|
Style::default().fg(Color::DarkGray),
|
|
))
|
|
.alignment(Alignment::Right)
|
|
};
|
|
f.render_widget(path_line, chunks[1]);
|
|
f.render_widget(header_block, area);
|
|
}
|
|
|
|
fn draw_status_dashboard(f: &mut Frame, area: Rect, app: &AppState) {
|
|
let live = Span::styled(
|
|
format!(" Save{}: {} ", if app.live_save_count == 1 { "" } else { "s" }, if app.save_path.is_some() { app.live_save_count.to_string() } else { "—".into() }),
|
|
Style::default().fg(Color::Green),
|
|
);
|
|
let bak = Span::styled(
|
|
format!(" Backup{}: {} ", if app.backup_count == 1 { "" } else { "s" }, app.backup_count),
|
|
Style::default().fg(Color::Yellow),
|
|
);
|
|
let ini = Span::styled(
|
|
format!(" .ini backup: {} ", if app.has_ini_backup { "yes" } else { "no" }),
|
|
Style::default().fg(if app.has_ini_backup { Color::Green } else { Color::DarkGray }),
|
|
);
|
|
|
|
let line = Line::from(vec![
|
|
Span::raw(" "),
|
|
live,
|
|
Span::raw(" "),
|
|
bak,
|
|
Span::raw(" "),
|
|
ini,
|
|
]);
|
|
|
|
f.render_widget(Paragraph::new(line), area);
|
|
}
|
|
|
|
fn draw_select_list(
|
|
f: &mut Frame,
|
|
area: Rect,
|
|
items: &[&str],
|
|
descs: &[&str],
|
|
prompt: &str,
|
|
state: &mut ListState,
|
|
) {
|
|
let list_area = Rect {
|
|
height: area.height.saturating_sub(1),
|
|
..area
|
|
};
|
|
|
|
let list_items: Vec<ListItem> = items
|
|
.iter()
|
|
.map(|item| {
|
|
ListItem::new(Span::raw(*item))
|
|
.style(Style::default())
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(list_items)
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(Color::Cyan)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol(" ")
|
|
.repeat_highlight_symbol(true);
|
|
|
|
f.render_stateful_widget(list, list_area, state);
|
|
|
|
// Description line for the highlighted item
|
|
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
|
|
let desc = descs.get(desc_idx).copied().unwrap_or("");
|
|
let desc_line = Paragraph::new(Span::styled(
|
|
format!(" {desc}"),
|
|
Style::default().fg(Color::DarkGray),
|
|
));
|
|
|
|
f.render_widget(
|
|
desc_line,
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y + area.height.saturating_sub(1),
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
);
|
|
|
|
// Prompt at bottom-right
|
|
let prompt_len = prompt.len() as u16;
|
|
if area.width > prompt_len + 2 {
|
|
let prompt_p = Paragraph::new(Span::styled(
|
|
prompt,
|
|
Style::default().fg(Color::DarkGray),
|
|
))
|
|
.alignment(Alignment::Right);
|
|
f.render_widget(
|
|
prompt_p,
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y + area.height.saturating_sub(1),
|
|
width: area.width.saturating_sub(2),
|
|
height: 1,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_select_list_with_info(
|
|
f: &mut Frame,
|
|
area: Rect,
|
|
items: &[&str],
|
|
descs: &[&str],
|
|
prompt: &str,
|
|
state: &mut ListState,
|
|
selected_info: Option<&str>,
|
|
) {
|
|
let extra = if selected_info.is_some() { 1u16 } else { 0u16 };
|
|
let list_area = Rect {
|
|
height: area.height.saturating_sub(1 + extra),
|
|
..area
|
|
};
|
|
|
|
let list_items: Vec<ListItem> = items
|
|
.iter()
|
|
.map(|item| {
|
|
ListItem::new(Span::raw(*item))
|
|
.style(Style::default())
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(list_items)
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(Color::Cyan)
|
|
.fg(Color::Black)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol(" ")
|
|
.repeat_highlight_symbol(true);
|
|
|
|
f.render_stateful_widget(list, list_area, state);
|
|
|
|
// Description line for the highlighted item
|
|
let base_y = area.y + area.height.saturating_sub(1 + extra);
|
|
let desc_idx = state.selected().unwrap_or(0).min(descs.len().saturating_sub(1));
|
|
let desc = descs.get(desc_idx).copied().unwrap_or("");
|
|
let desc_line = Paragraph::new(Span::styled(
|
|
format!(" {desc}"),
|
|
Style::default().fg(Color::DarkGray),
|
|
));
|
|
|
|
f.render_widget(
|
|
desc_line,
|
|
Rect {
|
|
x: area.x,
|
|
y: base_y,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
);
|
|
|
|
// Selected-item info line (e.g. filename)
|
|
if let Some(info) = selected_info {
|
|
let info_line = Paragraph::new(Span::styled(
|
|
format!(" → {info}"),
|
|
Style::default().fg(Color::White),
|
|
));
|
|
f.render_widget(
|
|
info_line,
|
|
Rect {
|
|
x: area.x,
|
|
y: base_y + 1,
|
|
width: area.width,
|
|
height: 1,
|
|
},
|
|
);
|
|
}
|
|
|
|
// Prompt at bottom-right
|
|
let prompt_len = prompt.len() as u16;
|
|
if area.width > prompt_len + 2 {
|
|
let prompt_p = Paragraph::new(Span::styled(
|
|
prompt,
|
|
Style::default().fg(Color::DarkGray),
|
|
))
|
|
.alignment(Alignment::Right);
|
|
f.render_widget(
|
|
prompt_p,
|
|
Rect {
|
|
x: area.x,
|
|
y: base_y,
|
|
width: area.width.saturating_sub(2),
|
|
height: 1,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
|
|
draw_whale_separator(f, area, app);
|
|
}
|
|
|
|
/// Separator line with a whale patrolling right-to-left.
|
|
/// Disappears for ~10 s after reaching the left edge.
|
|
pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
|
|
if area.width < 4 { return; }
|
|
let elapsed = app.whale_start.elapsed().as_millis() as u64;
|
|
let bar_w = area.width as u64;
|
|
let speed_ms: u64 = 180;
|
|
let cooldown_ticks: u64 = 30;
|
|
let total = bar_w + cooldown_ticks;
|
|
let t = (elapsed / speed_ms) % total;
|
|
if t < bar_w {
|
|
let x = bar_w - t - 1;
|
|
let switch = ((elapsed / 400) * 7 + (elapsed / 600) * 13) % 2;
|
|
let variants: &[&str] = &["🐋", "🐳"];
|
|
let whale = variants[(switch % variants.len() as u64) as usize];
|
|
f.render_widget(
|
|
Paragraph::new(Span::styled(whale, Style::default().fg(Color::Cyan))),
|
|
Rect { x: area.x + (x as u16).min(area.width.saturating_sub(4)), y: area.y, width: 4, height: 1 },
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Truncate a path to show the tail (most specific directories).
|
|
/// e.g. `C:\Users\...\Subnautica2\Saved\SaveGames` → `…\Subnautica2\Saved\SaveGames`
|
|
fn truncate_path_tail(path: &str, max_width: usize) -> String {
|
|
if path.len() <= max_width {
|
|
return path.to_string();
|
|
}
|
|
let keep = max_width.saturating_sub(3);
|
|
if keep == 0 {
|
|
return "…".to_string();
|
|
}
|
|
let tail = &path[path.len().saturating_sub(keep)..];
|
|
// Walk forward to a path separator so we don't split mid-component
|
|
if let Some(sep_pos) = tail.find(&['\\', '/'][..]) {
|
|
let start = path.len().saturating_sub(keep) + sep_pos;
|
|
format!("…{}", &path[start..])
|
|
} else {
|
|
format!("…{}", tail)
|
|
}
|
|
}
|
|
|
|
/// Create a centered rectangle for modal overlays.
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
])
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
])
|
|
.split(popup_layout[1])[1]
|
|
}
|
|
|
|
/// Shrink a rect by a margin on all sides.
|
|
fn inner(rect: Rect, margin_x: u16, margin_y: u16) -> Rect {
|
|
Rect {
|
|
x: rect.x + margin_x,
|
|
y: rect.y + margin_y,
|
|
width: rect.width.saturating_sub(margin_x * 2),
|
|
height: rect.height.saturating_sub(margin_y * 2),
|
|
}
|
|
}
|