mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-18 09:19:15 +02:00
layout alignment: restore/ini headers, info_dialog, ini restore header, yellow highlights
This commit is contained in:
+58
-34
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user