mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-23 19:12:37 +02:00
v0.4.1: persistent config, split-layout picker, backup location, security docs
This commit is contained in:
+118
-12
@@ -1,15 +1,42 @@
|
||||
//! Minimal path utilities — no persistent config.
|
||||
//! Path utilities and persistent app configuration.
|
||||
//!
|
||||
//! Save folder and Config/Windows paths are now session-only, entered
|
||||
//! via the `Set save folder` menu. The disclaimer acceptance is tracked
|
||||
//! via a 0-byte sentinel file (`NotAlterra_LICENSE_ACCEPTED`) next to the
|
||||
//! binary instead of a config file.
|
||||
//! The save-folder path and backup root are persisted to `app.ini` under the
|
||||
//! platform config directory so they survive between sessions. The disclaimer
|
||||
//! acceptance is tracked via a sentinel file in the same directory.
|
||||
//!
|
||||
//! The backup root defaults to `~/NotAlterra` and can be changed via the
|
||||
//! `Set backup location` menu item.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Path to the disclaimer sentinel file alongside the binary.
|
||||
/// Custom backup root directory, set via the `Set backup location` menu.
|
||||
/// When `None`, falls back to `exe_dir().join("backups")`.
|
||||
static BACKUP_ROOT: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
/// Set a custom backup root directory. Passes ownership.
|
||||
pub fn set_backup_root(path: PathBuf) {
|
||||
if let Ok(mut root) = BACKUP_ROOT.lock() {
|
||||
*root = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current backup root, or the default user-profile path.
|
||||
pub fn get_backup_root() -> PathBuf {
|
||||
BACKUP_ROOT
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|r| r.clone())
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join("NotAlterra"))
|
||||
.unwrap_or_else(exe_dir)
|
||||
})
|
||||
}
|
||||
|
||||
/// Path to the disclaimer sentinel file in the config directory.
|
||||
pub fn sentinel_path() -> PathBuf {
|
||||
exe_dir().join("NotAlterra_LICENSE_ACCEPTED")
|
||||
config_base_dir().join("NOTALTERRA_LICENSE_ACCEPTED")
|
||||
}
|
||||
|
||||
/// Return `true` if the disclaimer sentinel exists.
|
||||
@@ -39,22 +66,101 @@ pub fn cleanup_stale_config() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to the `backups/saves/` directory (tar.gz archives).
|
||||
/// Path to the `saves/` directory under the backup root (tar.gz archives).
|
||||
/// Auto-creates the directory on first call.
|
||||
pub fn backups_saves_dir() -> PathBuf {
|
||||
let p = exe_dir().join("backups").join("saves");
|
||||
let p = get_backup_root().join("backups").join("saves");
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
p
|
||||
}
|
||||
|
||||
/// Path to the `backups/config/` directory (.ini tar.gz archives).
|
||||
/// Auto-creates the directory on first call.
|
||||
/// Path to the `ue5/` subdirectory under the backup root for UE5 Config `.ini`
|
||||
/// backup archives. Auto-creates the directory on first call.
|
||||
pub fn backups_config_dir() -> PathBuf {
|
||||
let p = exe_dir().join("backups").join("config");
|
||||
let p = get_backup_root().join("backups").join("ue5");
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
p
|
||||
}
|
||||
|
||||
/// Return `~/NotAlterra` as the user's data directory for save/ue5 backups.
|
||||
/// Falls back to `exe_dir()` if home is not available.
|
||||
fn home_notalterra_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join("NotAlterra"))
|
||||
.unwrap_or_else(exe_dir)
|
||||
}
|
||||
|
||||
// ── persistent app config ────────────────────────────────────────────────────
|
||||
|
||||
/// Fixed base directory for `app.ini` and the sentinel file, under the
|
||||
/// standard platform config location. Separate from backup data so that
|
||||
/// `~/NotAlterra` remains the user's visible backup data directory.
|
||||
fn config_base_dir() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.map(|d| d.join("NotAlterra").join("config"))
|
||||
.unwrap_or_else(exe_dir)
|
||||
}
|
||||
|
||||
/// Path to the persistent `app.ini` configuration file.
|
||||
pub fn app_ini_path() -> PathBuf {
|
||||
let p = config_base_dir().join("app.ini");
|
||||
std::fs::create_dir_all(p.parent().unwrap_or(Path::new("."))).ok();
|
||||
p
|
||||
}
|
||||
|
||||
/// Session-lifetime configuration loaded from and persisted to `app.ini`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AppConfig {
|
||||
pub save_folder: Option<String>,
|
||||
pub backup_root: Option<String>,
|
||||
}
|
||||
|
||||
/// Load `app.ini` from the fixed config directory.
|
||||
/// Returns default (empty) values if the file does not exist or cannot be read.
|
||||
pub fn load_app_config() -> AppConfig {
|
||||
let path = app_ini_path();
|
||||
if !path.exists() {
|
||||
return AppConfig::default();
|
||||
}
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return AppConfig::default(),
|
||||
};
|
||||
let mut cfg = AppConfig::default();
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let val = value.trim();
|
||||
match key {
|
||||
"save_folder" => cfg.save_folder = Some(val.to_string()),
|
||||
"backup_root" => cfg.backup_root = Some(val.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Write the current session paths to `app.ini` in the fixed config directory.
|
||||
pub fn save_app_config(save_folder: Option<&str>, backup_root: Option<&str>) {
|
||||
let path = app_ini_path();
|
||||
let mut content = String::from(
|
||||
"# NotAlterra configuration\n\
|
||||
# This file is auto-generated. Edit while the tool is not running.\n\n",
|
||||
);
|
||||
if let Some(s) = save_folder {
|
||||
content.push_str(&format!("save_folder = {s}\n"));
|
||||
}
|
||||
if let Some(r) = backup_root {
|
||||
content.push_str(&format!("backup_root = {r}\n"));
|
||||
}
|
||||
let _ = std::fs::write(&path, content);
|
||||
}
|
||||
|
||||
/// Ensure a directory exists, creating all parents as needed.
|
||||
pub fn ensure_dir(path: PathBuf) {
|
||||
let _ = std::fs::create_dir_all(&path);
|
||||
|
||||
+6
-15
@@ -24,18 +24,6 @@ pub fn game_running() -> bool {
|
||||
// Close Subnautica 2 manually before using NotAlterra.
|
||||
false
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
/// Check if Subnautica 2 is running via tasklist (Windows). Dormant.
|
||||
fn _game_running_windows() -> bool {
|
||||
let out = std::process::Command::new("tasklist")
|
||||
.args(["/FI", "IMAGENAME eq Subnautica2.exe", "/NH"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).contains("Subnautica2.exe"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Subnautica 2 is currently running (Linux).
|
||||
///
|
||||
/// Always returns `false` — process detection via `pgrep` is disabled to
|
||||
@@ -100,10 +88,13 @@ pub fn migrate_old_log() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Path to `transaction.log` inside the `logs/` directory. All timestamped
|
||||
/// actions are appended here for audit trail purposes.
|
||||
/// Path to `transaction.log` inside the `logs/` directory under the
|
||||
/// platform config root. All timestamped actions are appended here for
|
||||
/// audit trail purposes.
|
||||
pub fn log_path() -> PathBuf {
|
||||
let p = exe_dir().join("logs").join("transaction.log");
|
||||
let p = dirs::data_local_dir()
|
||||
.map(|d| d.join("NotAlterra").join("logs").join("transaction.log"))
|
||||
.unwrap_or_else(|| exe_dir().join("logs").join("transaction.log"));
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
+207
-32
@@ -95,11 +95,6 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the backup root directory alongside the binary.
|
||||
fn _backup_root(&self) -> PathBuf {
|
||||
exe_dir().join("NotAlterra_Backups")
|
||||
}
|
||||
|
||||
/// Set the status bar message with optional style.
|
||||
fn set_status(&mut self, msg: &str, style: tui::StatusStyle) {
|
||||
self.tui_state.status_message = Some(msg.to_string());
|
||||
@@ -196,6 +191,22 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Load persistent app config — may override quick_discover
|
||||
let app_cfg = crate::config::load_app_config();
|
||||
if let Some(sf) = app_cfg.save_folder {
|
||||
let p = PathBuf::from(&sf);
|
||||
if p.exists() {
|
||||
app.save_folder = Some(p);
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
}
|
||||
}
|
||||
if let Some(br) = app_cfg.backup_root {
|
||||
let p = PathBuf::from(&br);
|
||||
if p.exists() {
|
||||
crate::config::set_backup_root(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Disclaimer flow
|
||||
if !crate::config::disclaimer_accepted() {
|
||||
match run_disclaimer(terminal, &mut app)? {
|
||||
@@ -218,34 +229,43 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
||||
tui::draw_main_menu(f, &mut menu_state, &app.tui_state);
|
||||
})?;
|
||||
|
||||
let max_idx = 7usize;
|
||||
const SKIP: &[usize] = &[2, 6, 8];
|
||||
let max_idx = 10usize;
|
||||
if event::poll(std::time::Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Release { continue; }
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
let i = menu_state.selected().unwrap_or(0);
|
||||
menu_state.select(Some(i.saturating_sub(1)));
|
||||
let mut i = menu_state.selected().unwrap_or(1);
|
||||
loop {
|
||||
i = i.saturating_sub(1);
|
||||
if !SKIP.contains(&i) || i == 0 { break; }
|
||||
}
|
||||
menu_state.select(Some(i));
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let i = menu_state.selected().unwrap_or(0);
|
||||
menu_state.select(Some((i + 1).min(max_idx)));
|
||||
let mut i = menu_state.selected().unwrap_or(0);
|
||||
loop {
|
||||
i = (i + 1).min(max_idx);
|
||||
if !SKIP.contains(&i) || i == max_idx { break; }
|
||||
}
|
||||
menu_state.select(Some(i));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let idx = menu_state.selected().unwrap_or(0);
|
||||
match idx {
|
||||
0 => action_set_save_folder(terminal, &mut app)?,
|
||||
1 => action_recover_bak(terminal, &mut app)?,
|
||||
2 => action_create_backup(terminal, &mut app)?,
|
||||
3 => action_restore_backup(terminal, &mut app)?,
|
||||
4 => action_inspect_saves(terminal, &mut app)?,
|
||||
5 => run_ini_submenu(terminal, &mut app)?,
|
||||
6 => {
|
||||
3 => action_set_backup_location(terminal, &mut app)?,
|
||||
4 => action_create_backup(terminal, &mut app)?,
|
||||
5 => action_restore_backup(terminal, &mut app)?,
|
||||
7 => run_ini_submenu(terminal, &mut app)?,
|
||||
9 => {
|
||||
if let Some(false) = run_disclaimer(terminal, &mut app)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
7 => return Ok(()), // Exit
|
||||
10 => return Ok(()), // Exit
|
||||
_ => {}
|
||||
}
|
||||
app.clear_status();
|
||||
@@ -335,6 +355,10 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
||||
if let Some(path) = candidate {
|
||||
app.save_folder = Some(path.clone());
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
crate::config::save_app_config(
|
||||
Some(&sanitized),
|
||||
Some(&crate::config::get_backup_root().to_string_lossy()),
|
||||
);
|
||||
let msg = format!("Save folder set to {}", path.display());
|
||||
app.set_status(&msg, tui::StatusStyle::Success);
|
||||
input_state.confirmed = true;
|
||||
@@ -392,6 +416,83 @@ fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the input dialog for the user to set a custom backup location.
|
||||
/// Pre-fills with the default `home_dir/NotAlterra` path for easy editing.
|
||||
fn action_set_backup_location<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
let default = dirs::home_dir()
|
||||
.map(|h| h.join("NotAlterra"))
|
||||
.unwrap_or_else(|| crate::config::exe_dir());
|
||||
let mut input_state = tui::InputDialogState::new(
|
||||
"Enter the path for storing backup archives:",
|
||||
);
|
||||
input_state.input = default.to_string_lossy().to_string();
|
||||
input_state.cursor = input_state.input.len();
|
||||
let mut ok_selected = true;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| {
|
||||
tui::draw_input_dialog(f, &app.tui_state, &input_state, ok_selected);
|
||||
})?;
|
||||
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Release { continue; }
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if ok_selected && !input_state.input.is_empty() {
|
||||
let sanitized: String = input_state.input.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
let path = std::path::PathBuf::from(&sanitized);
|
||||
crate::config::set_backup_root(path.clone());
|
||||
let save_str = app.save_folder.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||
crate::config::save_app_config(
|
||||
save_str.as_deref(),
|
||||
Some(&sanitized),
|
||||
);
|
||||
let msg = format!("Backup location set to {}", path.display());
|
||||
app.set_status(&msg, tui::StatusStyle::Success);
|
||||
input_state.confirmed = true;
|
||||
return Ok(());
|
||||
}
|
||||
input_state.cancelled = true;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char(c) if ok_selected => {
|
||||
input_state.insert(c);
|
||||
}
|
||||
KeyCode::Backspace if ok_selected => {
|
||||
input_state.backspace();
|
||||
}
|
||||
KeyCode::Delete if ok_selected => {
|
||||
input_state.delete();
|
||||
}
|
||||
KeyCode::Left if ok_selected => {
|
||||
input_state.cursor_left();
|
||||
}
|
||||
KeyCode::Right if ok_selected => {
|
||||
input_state.cursor_right();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
ok_selected = !ok_selected;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
input_state.cancelled = true;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Paste(s) if ok_selected => {
|
||||
input_state.insert_str(&s);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover a .sav from its .bak backup with a rollback safety net.
|
||||
fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
let save_folder = ensure_save_folder(terminal, app)?;
|
||||
@@ -424,8 +525,8 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
|
||||
// Build multi-column display with slot grouping.
|
||||
// First entry in each slot gets a numbered label matching the savegame slot.
|
||||
let mut labelled: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let header = format!(" {:<8} {:<16} {:<14} {:<8} {:>6} {}",
|
||||
"Slot", "Description", "Game Type", "Playtime", "Size", "Date");
|
||||
let header = format!(" {:<8} {:<24} {}",
|
||||
"Slot", "Description", "Date");
|
||||
let mut items: Vec<String> = vec![header, String::new()];
|
||||
items.extend(bak_summaries
|
||||
.iter()
|
||||
@@ -440,15 +541,10 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
|
||||
name.to_string()
|
||||
};
|
||||
let date = s.mtime.as_deref().unwrap_or("?");
|
||||
let save_type = if s.is_online { "Multiplayer" } else { "Single Player" };
|
||||
let playtime = format_playtime(s.playtime_seconds);
|
||||
format!(
|
||||
" {:<8} {:<16} {:<14} {:<8} {:>6} {}",
|
||||
" {:<8} {:<24} {}",
|
||||
label_col,
|
||||
name_col,
|
||||
save_type,
|
||||
playtime,
|
||||
format_size(s.size),
|
||||
date,
|
||||
)
|
||||
})
|
||||
@@ -467,19 +563,86 @@ fn action_recover_bak<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) ->
|
||||
let desc_refs: Vec<&str> = descs.iter().map(|s| s.as_str()).collect();
|
||||
let mut state = ListState::default().with_selected(Some(2)); // skip header + blank
|
||||
|
||||
// Lazily-loaded full metadata cache — loaded on first highlight
|
||||
let mut full_metas: Vec<Option<crate::gvas::FullMetadata>> = vec![None; bak_summaries.len()];
|
||||
|
||||
/// Load metadata for index `idx` if not already cached.
|
||||
fn ensure_meta(
|
||||
idx: usize,
|
||||
bak: &[ops::BakFileSummary],
|
||||
cache: &mut Vec<Option<crate::gvas::FullMetadata>>,
|
||||
) {
|
||||
if idx < cache.len() && cache[idx].is_none() {
|
||||
cache[idx] = crate::gvas::extract_full_metadata(&bak[idx].path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Build right-pane display lines from cached metadata.
|
||||
fn build_meta_lines<'a>(
|
||||
meta: Option<&'a crate::gvas::FullMetadata>,
|
||||
summary: &ops::BakFileSummary,
|
||||
) -> Vec<Line<'a>> {
|
||||
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
|
||||
|
||||
let Some(m) = meta else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let pt = m.playtime_seconds.or(summary.playtime_seconds);
|
||||
let playtime = format_playtime(pt);
|
||||
let fields: Vec<(&str, String)> = vec![
|
||||
("Slot", m.slot_name.as_deref().unwrap_or(&summary.slot).to_string()),
|
||||
("Name", m.display_name.as_deref().unwrap_or("(unnamed)").to_string()),
|
||||
("Playtime", playtime),
|
||||
("Game Type", m.game_mode.as_deref().unwrap_or("?").to_string()),
|
||||
("Mode", if m.is_online { "Multiplayer".into() } else { "Single Player".into() }),
|
||||
("Was Multi", if m.was_multiplayer { "Yes".into() } else { "No".into() }),
|
||||
("Branch", m.build_branch.as_deref().unwrap_or("?").to_string()),
|
||||
("Build", m.build_number.map_or("?".into(), |n| n.to_string())),
|
||||
];
|
||||
let max_label: usize = fields.iter().map(|(k, _)| k.len()).max().unwrap_or(6);
|
||||
fields
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let padded_label = format!("{:<max_label$}", k);
|
||||
Line::from(vec![
|
||||
Span::styled(padded_label, Style::default().fg(Color::White)),
|
||||
Span::raw(" "),
|
||||
Span::styled(v, dim),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
loop {
|
||||
let i = state.selected().unwrap_or(2).max(2);
|
||||
state.select(Some(i));
|
||||
let selected_info = filenames.get(i.saturating_sub(2)).map(|s| s.as_str());
|
||||
let sel_idx = i.saturating_sub(2);
|
||||
let selected_info = filenames.get(sel_idx).map(|s| s.as_str());
|
||||
let meta_header = selected_info.map(|f| format!("Details for {}", f));
|
||||
|
||||
// Lazy-load metadata on highlight
|
||||
if sel_idx < bak_summaries.len() {
|
||||
ensure_meta(sel_idx, &bak_summaries, &mut full_metas);
|
||||
}
|
||||
let meta = full_metas.get(sel_idx).and_then(|m| m.as_ref());
|
||||
let meta_lines = if let Some(m) = meta {
|
||||
build_meta_lines(Some(m), &bak_summaries[sel_idx])
|
||||
} else if sel_idx < bak_summaries.len() {
|
||||
Vec::new()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
terminal.draw(|f| {
|
||||
tui::draw_picker_with_info(
|
||||
tui::draw_picker_split(
|
||||
f,
|
||||
&app.tui_state,
|
||||
&item_refs,
|
||||
&desc_refs,
|
||||
&mut state,
|
||||
selected_info,
|
||||
meta_header.as_deref(),
|
||||
&meta_lines,
|
||||
);
|
||||
})?;
|
||||
if let Some(key) = poll_key(250)? {
|
||||
@@ -715,15 +878,19 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
|
||||
"Backup .ini files",
|
||||
"Restore .ini files from backup",
|
||||
"Delete .ini files (requires backup)",
|
||||
"",
|
||||
"Back",
|
||||
];
|
||||
let descs: Vec<&str> = vec![
|
||||
"Copy all .ini files from Config/Windows to NotAlterra_Backups",
|
||||
"Restore .ini files from a previous backup",
|
||||
"Remove .ini files — game regenerates defaults (backup required first)",
|
||||
"",
|
||||
"Return to main menu",
|
||||
];
|
||||
let mut state = ListState::default().with_selected(Some(0));
|
||||
const INI_SKIP: &[usize] = &[3];
|
||||
let ini_max = 4usize;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| {
|
||||
@@ -732,12 +899,20 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
|
||||
if let Some(key) = poll_key(250)? {
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
let i = state.selected().unwrap_or(0);
|
||||
state.select(Some(i.saturating_sub(1)));
|
||||
let mut i = state.selected().unwrap_or(1);
|
||||
loop {
|
||||
i = i.saturating_sub(1);
|
||||
if !INI_SKIP.contains(&i) || i == 0 { break; }
|
||||
}
|
||||
state.select(Some(i));
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let i = state.selected().unwrap_or(0);
|
||||
state.select(Some((i + 1).min(3)));
|
||||
let mut i = state.selected().unwrap_or(0);
|
||||
loop {
|
||||
i = (i + 1).min(ini_max);
|
||||
if !INI_SKIP.contains(&i) || i == ini_max { break; }
|
||||
}
|
||||
state.select(Some(i));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let idx = state.selected().unwrap_or(0);
|
||||
@@ -745,7 +920,7 @@ fn run_ini_submenu<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Res
|
||||
0 => ini_backup_action(terminal, app, &ini_path)?,
|
||||
1 => ini_restore_action(terminal, app, &ini_path)?,
|
||||
2 => ini_delete_action(terminal, app, &ini_path)?,
|
||||
3 => break,
|
||||
4 => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-8
@@ -78,22 +78,28 @@ 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 save folder",
|
||||
" Set Subnautica 2 location",
|
||||
" Recover save file",
|
||||
"",
|
||||
" Set backup location",
|
||||
" Create full backup",
|
||||
" Restore full backup",
|
||||
" Inspect save files",
|
||||
"",
|
||||
" Manage UE5 Config (.ini) files",
|
||||
"",
|
||||
" View disclaimer",
|
||||
" Exit",
|
||||
];
|
||||
let descs: Vec<&str> = vec![
|
||||
"Enter your save folder path manually",
|
||||
"Enter your Subnautica 2 save folder path (paste supported)",
|
||||
"Restore a save file from a backup",
|
||||
"",
|
||||
"Choose where backup archives are stored (default: next to the binary)",
|
||||
"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",
|
||||
];
|
||||
@@ -337,7 +343,7 @@ pub fn draw_picker_with_info(
|
||||
let chunks = standard_layout(f.area(), items.len());
|
||||
draw_header(f, chunks[0], app);
|
||||
|
||||
let prompt = "↑/↓ navigate Enter select Esc cancel";
|
||||
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);
|
||||
}
|
||||
@@ -447,11 +453,10 @@ fn draw_select_list(
|
||||
let list = List::new(list_items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(Color::Cyan)
|
||||
.fg(Color::Black)
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol(" ")
|
||||
.highlight_symbol("► ")
|
||||
.repeat_highlight_symbol(true);
|
||||
|
||||
f.render_stateful_widget(list, list_area, state);
|
||||
@@ -586,6 +591,157 @@ fn draw_select_list_with_info(
|
||||
}
|
||||
}
|
||||
|
||||
// ── pip-list renderer (for split-layout file picker) ─────────────────────
|
||||
|
||||
/// Render a compact pip-list without description line or prompt.
|
||||
/// The pip (►) replaces the full-row background highlight.
|
||||
fn draw_select_list_pip(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
items: &[&str],
|
||||
state: &mut ListState,
|
||||
) {
|
||||
if area.height < 2 || area.width < 10 { return; }
|
||||
|
||||
let dim_val = Style::default().fg(Color::Rgb(160, 160, 160));
|
||||
|
||||
let list_items: Vec<ListItem> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let style = if i == 0 {
|
||||
// Header row — match right pane header color
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||
} else if i >= 2 {
|
||||
// Data rows — match right pane value color
|
||||
dim_val
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(Span::raw(*item)).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(list_items)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("► ")
|
||||
.repeat_highlight_symbol(true);
|
||||
|
||||
f.render_stateful_widget(list, area, state);
|
||||
}
|
||||
|
||||
// ── right-pane metadata panel ────────────────────────────────────────────
|
||||
|
||||
/// Render the right-hand metadata pane in the split file picker.
|
||||
/// Shows the filename header, a dim separator, then the provided content lines.
|
||||
/// When `meta_lines` is empty, shows a placeholder message.
|
||||
fn draw_right_pane(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
filename: &str,
|
||||
meta_lines: &[Line],
|
||||
) {
|
||||
if area.height < 3 || area.width < 10 { return; }
|
||||
|
||||
let dim = Style::default().fg(Color::Rgb(160, 160, 160));
|
||||
|
||||
// Filename header
|
||||
let mut y = area.y;
|
||||
let fname = if filename.len() as u16 > area.width.saturating_sub(2) {
|
||||
format!("{}…", &filename[..area.width.saturating_sub(3).max(1) as usize])
|
||||
} else {
|
||||
filename.to_string()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(&fname, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))),
|
||||
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
|
||||
);
|
||||
y += 2;
|
||||
|
||||
if meta_lines.is_empty() {
|
||||
// Placeholder
|
||||
let msg = if filename.is_empty() {
|
||||
Span::styled("select a file", dim)
|
||||
} else {
|
||||
Span::styled("select to load metadata", dim)
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(msg),
|
||||
Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Content lines
|
||||
let max_lines = area.height.saturating_sub(2) as usize;
|
||||
for (i, line) in meta_lines.iter().enumerate().take(max_lines) {
|
||||
f.render_widget(
|
||||
Paragraph::new(line.clone()),
|
||||
Rect { x: area.x + 1, y: y + i as u16, width: area.width.saturating_sub(2), height: 1 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── split-layout picker entry point ──────────────────────────────────────
|
||||
|
||||
/// Draw the file picker with a horizontal split: pip-style file list on the
|
||||
/// left, live metadata preview on the right. Used by the .bak recover flow.
|
||||
pub fn draw_picker_split(
|
||||
f: &mut Frame,
|
||||
app: &AppState,
|
||||
items: &[&str],
|
||||
_descs: &[&str],
|
||||
state: &mut ListState,
|
||||
selected_info: Option<&str>,
|
||||
meta_lines: &[Line],
|
||||
) {
|
||||
let chunks = standard_layout(f.area(), items.len());
|
||||
draw_header(f, chunks[0], app);
|
||||
|
||||
let menu_area = chunks[2];
|
||||
let prompt = "↑/↓ navigate | Enter select | Esc cancel";
|
||||
|
||||
// Reserve bottom row of menu area for the prompt
|
||||
let prompt_y = menu_area.y + menu_area.height.saturating_sub(1);
|
||||
let content_area = Rect {
|
||||
height: menu_area.height.saturating_sub(1),
|
||||
..menu_area
|
||||
};
|
||||
|
||||
// Split content into left (60%) and right (40%)
|
||||
let halves = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(content_area);
|
||||
|
||||
// Left: pip list (full height of content area)
|
||||
draw_select_list_pip(f, halves[0], items, state);
|
||||
|
||||
// Right: metadata pane
|
||||
draw_right_pane(f, halves[1], selected_info.unwrap_or(""), meta_lines);
|
||||
|
||||
// Prompt at bottom-right of the full menu area
|
||||
let prompt_len = prompt.len() as u16;
|
||||
if menu_area.width > prompt_len + 2 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray)))
|
||||
.alignment(Alignment::Right),
|
||||
Rect {
|
||||
x: menu_area.x,
|
||||
y: prompt_y,
|
||||
width: menu_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
draw_status_bar(f, chunks[3], app);
|
||||
}
|
||||
|
||||
/// Render the status bar at the bottom of the screen.
|
||||
fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) {
|
||||
draw_whale_separator(f, area, app);
|
||||
|
||||
Reference in New Issue
Block a user