layout alignment: restore/ini headers, info_dialog, ini restore header, yellow highlights

This commit is contained in:
2026-06-09 16:02:25 +02:00
parent 56a4b7ac16
commit b2fc557e4f
3 changed files with 160 additions and 61 deletions
+58 -34
View File
@@ -871,19 +871,15 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
fn action_create_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let save_folder = ensure_save_folder(terminal, app)?;
info_dialog(
terminal,
app,
"Creating Backup",
"NotAlterra is backing up your save files.",
)?;
app.set_status("Creating backup…", tui::StatusStyle::Info);
app.set_spinner(true);
terminal.draw(|f| {
tui::draw_text_screen(
f,
&app.tui_state,
&[Line::from(Span::styled(
"Creating full backup…",
Style::default().add_modifier(Modifier::BOLD),
))],
"Copying save folder contents…",
);
})?;
match ops::create_full_backup(&save_folder) {
Ok(result) => {
@@ -926,16 +922,20 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
return Ok(());
}
let items: Vec<String> = backups
let header = format!(" {:<38} {:>8}", "Backup", "Size");
let mut items: Vec<String> = vec![header, String::new()];
items.extend(backups
.iter()
.map(|p| {
let name = p.file_name().unwrap().to_string_lossy();
format!(" {}", format_backup_label(&name))
let size = std::fs::metadata(p).map(|m| format_size(m.len())).unwrap_or("?".into());
format!(" {:<42} {:>8}", format_backup_label(&name), size)
})
.collect();
let descs: Vec<String> = backups
.iter()
.map(|p| {
.collect::<Vec<String>>());
// Prepend empty description entries for header + blank
let descs: Vec<String> = std::iter::once(String::new())
.chain(std::iter::once(String::new()))
.chain(backups.iter().map(|p| {
let name = p.file_name().unwrap().to_string_lossy().to_string();
if !ops::check_tar_gz_integrity(p) {
"⚠ Corrupted — file does not appear to be a valid backup archive".into()
@@ -946,11 +946,11 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
} else {
"Restore save folder from this full backup".into()
}
})
}))
.collect();
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect();
let mut state = ListState::default().with_selected(Some(0));
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
loop {
// Restore picker: show backup root in header
@@ -958,7 +958,7 @@ fn action_restore_backup<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
crate::config::get_backup_root().to_string_lossy().to_string(),
);
terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &desc_refs, &mut state);
tui::draw_picker(f, &app.tui_state, &item_refs, &desc_refs, &mut state, true);
})?;
if let Some(key) = poll_key(250)? {
match key.code {
@@ -1053,11 +1053,11 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
let ini_path = get_ini_path(terminal, app)?;
let items: Vec<&str> = vec![
"Backup .ini files",
"Restore .ini files from backup",
"Delete .ini files (requires backup)",
" Backup .ini files",
" Restore .ini files from backup",
" Delete .ini files (requires backup)",
"",
"Back",
" Back",
];
let descs: Vec<&str> = vec![
"Copy all .ini files from Config/Windows to NotAlterra_Backups",
@@ -1142,6 +1142,13 @@ fn ini_backup_action<B: Backend>(
app: &mut App,
ini_path: &Path,
) -> Result<()> {
info_dialog(
terminal,
app,
"Creating .ini Backup",
"NotAlterra is backing up your\nUE5 Config (.ini) files.",
)?;
match ops::backup_ini_files(ini_path) {
Ok(result) => {
let verified = if result.verified {
@@ -1200,16 +1207,19 @@ fn ini_restore_action<B: Backend>(
return Ok(());
}
let items: Vec<String> = backups
.iter()
.map(|p| {
let name = p.file_name().unwrap().to_string_lossy();
format!(" {}", format_backup_label(&name))
})
.collect();
let header = format!(" {:<38} {:>8}", "INI Backup", "Size");
let mut items: Vec<String> = vec![header, String::new()];
items.extend(backups.iter().map(|p| {
let name = p.file_name().unwrap().to_string_lossy();
let size = std::fs::metadata(p).map(|m| format_size(m.len())).unwrap_or("?".into());
format!(" {:<42} {:>8}", format_backup_label(&name), size)
}));
let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let ini_descs: Vec<&str> = vec!["Restore .ini files from this backup"; backups.len()];
let mut state = ListState::default().with_selected(Some(0));
let ini_descs: Vec<&str> = std::iter::once("")
.chain(std::iter::once(""))
.chain(std::iter::repeat("Restore .ini files from this backup").take(backups.len()))
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
loop {
// Restore picker: show backup root in header
@@ -1217,7 +1227,7 @@ fn ini_restore_action<B: Backend>(
crate::config::get_backup_root().to_string_lossy().to_string(),
);
terminal.draw(|f| {
tui::draw_picker(f, &app.tui_state, &item_refs, &ini_descs, &mut state);
tui::draw_picker(f, &app.tui_state, &item_refs, &ini_descs, &mut state, true);
})?;
if let Some(key) = poll_key(250)? {
match key.code {
@@ -1386,6 +1396,7 @@ fn action_inspect_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -
&desc_refs,
&mut state,
selected_info,
false,
);
})?;
if let Some(key) = poll_key(250)? {
@@ -1568,6 +1579,19 @@ fn ok_dialog_styled<B: Backend>(
}
}
/// Display a non-interactive info dialog — no buttons, renders once.
/// The dialog stays on screen until the next `terminal.draw()` call
/// replaces it. Use for brief status messages before a blocking operation.
fn info_dialog<B: Backend>(
terminal: &mut Terminal<B>,
app: &App,
title: &str,
msg: &str,
) -> Result<()> {
terminal.draw(|f| tui::draw_info_dialog(f, &app.tui_state, title, msg))?;
Ok(())
}
/// Display a plain-text informational dialog with a single OK button.
/// `msg` supports newlines for multi-line messages. Press Enter or
/// Space to dismiss. For styled content, use `ok_dialog_styled`.
+2 -3
View File
@@ -654,9 +654,8 @@ mod tests {
let path = dir.path().join("test.tar.gz");
// Write a minimal valid gzip file (20 bytes of gzip stream)
let valid_gzip = [
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
fs::write(&path, &valid_gzip).unwrap();
assert!(check_tar_gz_integrity(&path));
+100 -24
View File
@@ -82,17 +82,17 @@ impl Default for AppState {
/// Draw the main menu.
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
let items: Vec<&str> = vec![
" Set Subnautica 2 location",
" Recover save file",
" Set Subnautica 2 location",
" Recover save file",
"",
" Set backup location",
" Create full backup",
" Restore full backup",
" Set backup location",
" Create full backup",
" Restore full backup",
"",
" Manage UE5 Config (.ini) files",
" Manage UE5 Config (.ini) files",
"",
" View disclaimer",
" Exit",
" View disclaimer",
" Exit",
];
let descs: Vec<&str> = vec![
"Enter your Subnautica 2 save folder path (paste supported)",
@@ -115,7 +115,7 @@ pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) {
let prompt = "↑/↓ navigate Enter select";
draw_select_list(f, chunks[2], &items, &descs, prompt, state);
draw_status_bar(f, chunks[3], app);
draw_status_bar(f, chunks[4], app);
}
/// Draw the disclaimer popup with full warning text.
@@ -394,6 +394,61 @@ pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str)
draw_whale_separator(f, bar, app);
}
/// Render a non-interactive info dialog — no buttons, renders once, caller
/// is expected to return to the event loop (the dialog stays visible until
/// the next `terminal.draw()` replaces it).
pub fn draw_info_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 + 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::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,
},
);
// No button — this is informational only
// 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);
}
/// Render a dialog with styled content lines. Supports inline formatting
/// (colors, bold) via [`Line`] slices. Use for metadata displays, help
/// text, or any content that needs per-span styling.
@@ -498,9 +553,9 @@ pub fn draw_sub_menu(
draw_header(f, chunks[0], app);
let title_p = Paragraph::new(Span::styled(
title,
format!(" {title}"),
Style::default()
.fg(Color::Yellow)
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
f.render_widget(title_p, chunks[1]);
@@ -513,7 +568,7 @@ pub fn draw_sub_menu(
"↑/↓ navigate Enter select Esc back",
state,
);
draw_status_bar(f, chunks[3], app);
draw_status_bar(f, chunks[4], app);
}
/// Draw a full-screen text display with a "press any key" prompt at the
@@ -527,22 +582,25 @@ pub fn draw_text_screen(f: &mut Frame, app: &AppState, lines: &[Line], prompt: &
let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center);
f.render_widget(prompt_p, chunks[3]);
f.render_widget(prompt_p, chunks[4]);
}
/// Draw a file/folder picker list.
/// `pinned_header` renders items[0] as a fixed header above the scrollable list.
pub fn draw_picker(
f: &mut Frame,
app: &AppState,
items: &[&str],
descs: &[&str],
state: &mut ListState,
pinned_header: bool,
) {
draw_picker_with_info(f, app, items, descs, state, None);
draw_picker_with_info(f, app, items, descs, state, None, pinned_header);
}
/// Draw a file/folder picker list with an extra selected-item info line
/// (e.g. showing the full filename of the highlighted .bak file).
/// `pinned_header` renders items[0] as a fixed header above the scrollable list.
pub fn draw_picker_with_info(
f: &mut Frame,
app: &AppState,
@@ -550,13 +608,14 @@ pub fn draw_picker_with_info(
descs: &[&str],
state: &mut ListState,
selected_info: Option<&str>,
pinned_header: bool,
) {
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);
draw_select_list_with_info(f, chunks[2], items, descs, prompt, state, selected_info, pinned_header);
draw_status_bar(f, chunks[4], app);
}
// ── internal drawing helpers ───────────────────────────────────────────────
@@ -568,6 +627,7 @@ fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
Constraint::Length(3), // header
Constraint::Length(2), // dashboard
Constraint::Min(1), // menu (fills remaining)
Constraint::Length(1), // spacer
Constraint::Length(1), // status bar
])
.split(area)
@@ -701,7 +761,7 @@ fn draw_select_list(
let list = List::new(list_items)
.highlight_style(
Style::default()
.fg(Color::White)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("")
@@ -756,14 +816,31 @@ fn draw_select_list_with_info(
prompt: &str,
state: &mut ListState,
selected_info: Option<&str>,
pinned_header: bool,
) {
let extra = if selected_info.is_some() { 1u16 } else { 0u16 };
// If pinned_header is set, items[0] (header) and items[1] (blank spacer)
// render as fixed rows above the scrollable list. items[2..] form the list.
let (list_start_y, list_items_slice): (u16, &[&str]) = if pinned_header && !items.is_empty() {
let header_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
f.render_widget(
Paragraph::new(Span::styled(items[0], header_style)),
Rect { x: area.x, y: area.y, width: area.width, height: 1 },
);
// Leave items[1] (blank spacer) as visual gap — rendered as empty row
(area.y + 2, &items[2..])
} else {
(area.y, items)
};
let list_area = Rect {
height: area.height.saturating_sub(1 + extra),
y: list_start_y,
height: area.height.saturating_sub(2 + extra + (list_start_y - area.y)),
..area
};
let list_items: Vec<ListItem> = items
let list_items: Vec<ListItem> = list_items_slice
.iter()
.map(|item| ListItem::new(Span::raw(*item)).style(Style::default()))
.collect();
@@ -771,11 +848,10 @@ fn draw_select_list_with_info(
let list = List::new(list_items)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ")
.highlight_symbol(" ")
.repeat_highlight_symbol(true);
f.render_stateful_widget(list, list_area, state);
@@ -869,7 +945,7 @@ fn draw_select_list_pip(f: &mut Frame, area: Rect, items: &[&str], state: &mut L
let list = List::new(list_items)
.highlight_style(
Style::default()
.fg(Color::White)
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("")
@@ -1003,7 +1079,7 @@ pub fn draw_picker_split(
);
}
draw_status_bar(f, chunks[3], app);
draw_status_bar(f, chunks[4], app);
}
/// Render the status bar at the bottom of the screen.