From 7730524ee2c75d7a1315ee306baa9eeb8a5d5949 Mon Sep 17 00:00:00 2001 From: forkless Date: Tue, 9 Jun 2026 11:43:33 +0200 Subject: [PATCH] fix clippy warnings and cargo fmt issues --- examples/dump_samples.rs | 28 +- src/config.rs | 2 - src/discovery.rs | 84 +++-- src/guard.rs | 7 +- src/gvas.rs | 203 ++++++++--- src/main.rs | 723 +++++++++++++++++++++++++++------------ src/ops.rs | 174 +++++++--- src/tui.rs | 555 ++++++++++++++++++++++-------- 8 files changed, 1276 insertions(+), 500 deletions(-) diff --git a/examples/dump_samples.rs b/examples/dump_samples.rs index 1dd86ac..5b9e6bd 100644 --- a/examples/dump_samples.rs +++ b/examples/dump_samples.rs @@ -20,8 +20,7 @@ fn main() { entries.sort_by(|a, b| { let ma = a.metadata().ok().and_then(|m| m.modified().ok()); let mb = b.metadata().ok().and_then(|m| m.modified().ok()); - mb.cmp(&ma) - .then_with(|| a.file_name().cmp(&b.file_name())) + mb.cmp(&ma).then_with(|| a.file_name().cmp(&b.file_name())) }); println!( @@ -41,16 +40,17 @@ fn main() { .and_then(|m| m.modified().ok()) .and_then(|t| { let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); - chrono::Local - .timestamp_opt(secs as i64, 0) - .single() + chrono::Local.timestamp_opt(secs as i64, 0).single() }) .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()); let meta = notalterra::gvas::extract_metadata(&path).ok(); - let slot = meta.as_ref().and_then(|m| m.slot_name.clone()).unwrap_or_else(|| { - notalterra::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()) - }); + let slot = meta + .as_ref() + .and_then(|m| m.slot_name.clone()) + .unwrap_or_else(|| { + notalterra::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()) + }); let display = meta .as_ref() .and_then(|m| m.display_name.clone()) @@ -59,9 +59,17 @@ fn main() { let label_num = slot.strip_prefix("savegame_").unwrap_or(&slot); let first = seen.insert(slot.clone()); - let label = if first { format!("Slot {label_num}") } else { String::new() }; + let label = if first { + format!("Slot {label_num}") + } else { + String::new() + }; - let typ = if is_online { "Multiplayer" } else { "Single Player" }; + let typ = if is_online { + "Multiplayer" + } else { + "Single Player" + }; let sz = if size < 1024 { format!("{size} B") } else if size < 1024 * 1024 { diff --git a/src/config.rs b/src/config.rs index ea14833..bb13d5b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,5 +173,3 @@ pub fn exe_dir() -> PathBuf { .and_then(|p| p.parent().map(Path::to_path_buf)) .unwrap_or_else(|| PathBuf::from(".")) } - - diff --git a/src/discovery.rs b/src/discovery.rs index c7f8a63..5a01d65 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -19,10 +19,19 @@ pub struct DiscoveredFolder { /// The same patterns work on both platforms because UE5 keeps the same /// directory layout regardless of OS. const KNOWN_PATTERNS: &[(&str, &str)] = &[ - ("Steam (LocalLow)", "AppData/LocalLow/Unknown Worlds/Subnautica2"), - ("Steam (LocalLow, alt)", "AppData/LocalLow/Unknown Worlds/Subnautica 2"), + ( + "Steam (LocalLow)", + "AppData/LocalLow/Unknown Worlds/Subnautica2", + ), + ( + "Steam (LocalLow, alt)", + "AppData/LocalLow/Unknown Worlds/Subnautica 2", + ), ("AppData Local", "AppData/Local/Subnautica2/Saved/SaveGames"), - ("AppData Local (alt)", "AppData/Local/Subnautica 2/Saved/SaveGames"), + ( + "AppData Local (alt)", + "AppData/Local/Subnautica 2/Saved/SaveGames", + ), ("Xbox / Game Pass", "AppData/Local/Packages"), // partial — needs wildcard below ("Saved Games", "Saved Games/Subnautica2"), ("Saved Games (alt)", "Saved Games/Subnautica 2"), @@ -91,13 +100,16 @@ pub fn discover_save_folders() -> Vec { if let Some(home) = dirs::home_dir() { for (label, rel) in KNOWN_PATTERNS { let candidate = home.join(rel); - if candidate.exists() && candidate.is_dir() - && has_save_files(&candidate) && seen.insert(candidate.clone()) { - found.push(DiscoveredFolder { - label: label.to_string(), - path: candidate, - }); - } + if candidate.exists() + && candidate.is_dir() + && has_save_files(&candidate) + && seen.insert(candidate.clone()) + { + found.push(DiscoveredFolder { + label: label.to_string(), + path: candidate, + }); + } } } @@ -140,11 +152,9 @@ fn has_save_files(dir: &Path) -> bool { let check = |ext: &str| -> bool { fs::read_dir(dir) .map(|entries| { - entries.flatten().any(|e| { - e.file_name() - .to_string_lossy() - .ends_with(ext) - }) + entries + .flatten() + .any(|e| e.file_name().to_string_lossy().ends_with(ext)) }) .unwrap_or(false) }; @@ -167,7 +177,10 @@ fn scan_other_users( let user_path = user_dir.path(); for (label, rel) in KNOWN_PATTERNS { let candidate = user_path.join(rel); - if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) { + if candidate.exists() + && has_save_files(&candidate) + && seen.insert(candidate.clone()) + { found.push(DiscoveredFolder { label: label.to_string(), path: candidate, @@ -195,7 +208,10 @@ fn scan_other_users( let user_path = user_dir.path(); for (label, rel) in KNOWN_PATTERNS { let candidate = user_path.join(rel); - if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) { + if candidate.exists() + && has_save_files(&candidate) + && seen.insert(candidate.clone()) + { found.push(DiscoveredFolder { label: label.to_string(), path: candidate, @@ -217,7 +233,10 @@ fn scan_other_users( let pfx = app_entry.path().join("pfx/drive_c/users/steamuser"); for (label, rel) in KNOWN_PATTERNS { let candidate = pfx.join(rel); - if candidate.exists() && has_save_files(&candidate) && seen.insert(candidate.clone()) { + if candidate.exists() + && has_save_files(&candidate) + && seen.insert(candidate.clone()) + { found.push(DiscoveredFolder { label: format!("Steam Deck — {label}"), path: candidate, @@ -260,11 +279,7 @@ fn scan_common_install_dirs( found: &mut Vec, seen: &mut std::collections::HashSet, ) { - let roots: &[&str] = &[ - "/opt", - "/usr/local/games", - "/usr/share/games", - ]; + let roots: &[&str] = &["/opt", "/usr/local/games", "/usr/share/games"]; for rt in roots { let p = Path::new(rt); if !p.exists() { @@ -295,7 +310,10 @@ fn walk_for_subnautica( while let Some(dir) = queue.pop_front() { // Limit depth: don't recurse more than 5 levels from root - let depth = dir.components().count().saturating_sub(root.components().count()); + let depth = dir + .components() + .count() + .saturating_sub(root.components().count()); if depth > 5 { continue; } @@ -307,15 +325,17 @@ fn walk_for_subnautica( for entry in entries.flatten() { let path = entry.path(); - let name = path.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_lowercase()) + .unwrap_or_default(); - if name.contains("subnautica") - && has_save_files(&path) && seen.insert(path.clone()) { - found.push(DiscoveredFolder { - label: label.to_string(), - path: path.clone(), - }); - } + if name.contains("subnautica") && has_save_files(&path) && seen.insert(path.clone()) { + found.push(DiscoveredFolder { + label: label.to_string(), + path: path.clone(), + }); + } if path.is_dir() { queue.push_back(path); diff --git a/src/guard.rs b/src/guard.rs index d3abce6..470ea78 100644 --- a/src/guard.rs +++ b/src/guard.rs @@ -117,12 +117,7 @@ const MAX_LOG_LINES: usize = 10_000; /// Format: `YYYY-MM-DD HH:MM:SS | ACTION | detail | result` /// Auto-rotates if the log exceeds 10,000 lines — the oldest lines are /// discarded, keeping only the most recent 10,000. -pub fn log_action( - action: &str, - detail: &str, - result: &str, - log_path: &Path, -) -> Result<()> { +pub fn log_action(action: &str, detail: &str, result: &str, log_path: &Path) -> Result<()> { let stamp = Local::now().format("%Y-%m-%d %H:%M:%S"); let line = format!("{stamp} | {action:<8} | {detail} | {result}\n"); diff --git a/src/gvas.rs b/src/gvas.rs index d95e8c2..fa23af5 100644 --- a/src/gvas.rs +++ b/src/gvas.rs @@ -1,5 +1,9 @@ //! UE4/UE5 GVAS save-file binary parser. //! +//! The GVAS serialization format is defined by the public Unreal Engine 5 +//! source code — the SaveGame system and its binary layout are part of the +//! engine's open API. +//! //! Ported from `legacy/extract_save_name.py`. Extracts `SlotName` and //! `DisplayName` properties via manual binary walking, plus corruption //! detection by cross-referencing metadata against the canonical filename @@ -182,17 +186,42 @@ fn extract_bool_property(data: &[u8], prop_name: &str) -> Option { let mut offset = 0usize; let mut attempts = 0u32; while offset < data.len().saturating_sub(20) && attempts < 100 { - let found = data[offset..].windows(target.len()).position(|w| w == target); - let found = match found { Some(p) => offset + p, None => return None }; - if found < 4 { offset = found + 1; attempts += 1; continue; } + let found = data[offset..] + .windows(target.len()) + .position(|w| w == target); + let found = match found { + Some(p) => offset + p, + None => return None, + }; + if found < 4 { + offset = found + 1; + attempts += 1; + continue; + } let name_len_field = read_u32(data, found - 4); - if name_len_field != Some(target.len() + 1) { offset = found + 1; attempts += 1; continue; } - if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; } + if name_len_field != Some(target.len() + 1) { + offset = found + 1; + attempts += 1; + continue; + } + if found + target.len() >= data.len() || data[found + target.len()] != 0 { + offset = found + 1; + attempts += 1; + continue; + } let after_name = found + target.len() + 1; let (next_name, next_offset) = read_fname(data, after_name); - if next_name.as_deref() != Some("BoolProperty") { offset = found + 1; attempts += 1; continue; } + if next_name.as_deref() != Some("BoolProperty") { + offset = found + 1; + attempts += 1; + continue; + } let val_offset = next_offset + 9; - if val_offset >= data.len() { offset = found + 1; attempts += 1; continue; } + if val_offset >= data.len() { + offset = found + 1; + attempts += 1; + continue; + } return Some(data[val_offset] != 0); } None @@ -203,8 +232,10 @@ fn scan_double_near(data: &[u8], marker: &[u8]) -> Option { let pos = data.windows(marker.len()).position(|w| w == marker)?; let _end = (pos + 60).min(data.len()); for off in 8..50 { - if pos + off + 8 > data.len() { break; } - let val = f64::from_le_bytes(data[pos+off..pos+off+8].try_into().ok()?); + if pos + off + 8 > data.len() { + break; + } + let val = f64::from_le_bytes(data[pos + off..pos + off + 8].try_into().ok()?); if val > 60.0 && val < 10_000_000.0 { return Some(val); } @@ -218,17 +249,44 @@ fn extract_double_property(data: &[u8], prop_name: &str) -> Option { let mut offset = 0usize; let mut attempts = 0u32; while offset < data.len().saturating_sub(30) && attempts < 100 { - let found = data[offset..].windows(target.len()).position(|w| w == target); - let found = match found { Some(p) => offset + p, None => return None }; - if found < 4 { offset = found + 1; attempts += 1; continue; } + let found = data[offset..] + .windows(target.len()) + .position(|w| w == target); + let found = match found { + Some(p) => offset + p, + None => return None, + }; + if found < 4 { + offset = found + 1; + attempts += 1; + continue; + } let expected: usize = target.len() + 1; - if read_u32(data, found - 4) != Some(expected) { offset = found + 1; attempts += 1; continue; } - if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; } + if read_u32(data, found - 4) != Some(expected) { + offset = found + 1; + attempts += 1; + continue; + } + if found + target.len() >= data.len() || data[found + target.len()] != 0 { + offset = found + 1; + attempts += 1; + continue; + } let (next_name, next_offset) = read_fname(data, found + target.len() + 1); - if next_name.as_deref() != Some("DoubleProperty") { offset = found + 1; attempts += 1; continue; } + if next_name.as_deref() != Some("DoubleProperty") { + offset = found + 1; + attempts += 1; + continue; + } let val_offset = next_offset + 9; - if val_offset + 8 > data.len() { offset = found + 1; attempts += 1; continue; } - return Some(f64::from_le_bytes(data[val_offset..val_offset+8].try_into().ok()?)); + if val_offset + 8 > data.len() { + offset = found + 1; + attempts += 1; + continue; + } + return Some(f64::from_le_bytes( + data[val_offset..val_offset + 8].try_into().ok()?, + )); } None } @@ -239,15 +297,40 @@ fn extract_int_property(data: &[u8], prop_name: &str) -> Option { let mut offset = 0usize; let mut attempts = 0u32; while offset < data.len().saturating_sub(20) && attempts < 100 { - let found = data[offset..].windows(target.len()).position(|w| w == target); - let found = match found { Some(p) => offset + p, None => return None }; - if found < 4 { offset = found + 1; attempts += 1; continue; } - if read_u32(data, found - 4) != Some(target.len() + 1) { offset = found + 1; attempts += 1; continue; } - if found + target.len() >= data.len() || data[found + target.len()] != 0 { offset = found + 1; attempts += 1; continue; } + let found = data[offset..] + .windows(target.len()) + .position(|w| w == target); + let found = match found { + Some(p) => offset + p, + None => return None, + }; + if found < 4 { + offset = found + 1; + attempts += 1; + continue; + } + if read_u32(data, found - 4) != Some(target.len() + 1) { + offset = found + 1; + attempts += 1; + continue; + } + if found + target.len() >= data.len() || data[found + target.len()] != 0 { + offset = found + 1; + attempts += 1; + continue; + } let (next_name, next_offset) = read_fname(data, found + target.len() + 1); - if next_name.as_deref() != Some("IntProperty") { offset = found + 1; attempts += 1; continue; } + if next_name.as_deref() != Some("IntProperty") { + offset = found + 1; + attempts += 1; + continue; + } let val_offset = next_offset + 9; - if val_offset + 4 > data.len() { offset = found + 1; attempts += 1; continue; } + if val_offset + 4 > data.len() { + offset = found + 1; + attempts += 1; + continue; + } return read_u32(data, val_offset).map(|v| v as u32); } None @@ -274,8 +357,7 @@ pub struct FullMetadata { /// Parse a `.sav` or `.bak` file and return all known GVAS metadata. pub fn extract_full_metadata(path: &Path) -> Result { - let data = fs::read(path) - .with_context(|| format!("failed to read {}", path.display()))?; + let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; Ok(FullMetadata { slot_name: extract_str_property(&data, "SlotName").ok(), display_name: extract_str_property(&data, "DisplayName").ok(), @@ -318,11 +400,17 @@ pub fn extract_metadata_from_bytes(data: &[u8]) -> Result { let mut errors = Vec::new(); let slot_name = match extract_str_property(data, "SlotName") { Ok(v) => Some(v), - Err(e) => { errors.push(e); None } + Err(e) => { + errors.push(e); + None + } }; let display_name = match extract_str_property(data, "DisplayName") { Ok(v) => Some(v), - Err(e) => { errors.push(e); None } + Err(e) => { + errors.push(e); + None + } }; Ok(SaveMetadata { slot_name, @@ -334,8 +422,7 @@ pub fn extract_metadata_from_bytes(data: &[u8]) -> Result { } pub fn extract_metadata(path: &Path) -> Result { - let data = fs::read(path) - .with_context(|| format!("failed to read {}", path.display()))?; + let data = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; let mut errors = Vec::new(); let slot_name = match extract_str_property(&data, "SlotName") { @@ -431,10 +518,7 @@ mod tests { #[test] fn test_corruption_check() { // Canonical live file - assert_eq!( - corruption_check("savegame_0.sav", Some("savegame_0")), - None - ); + assert_eq!(corruption_check("savegame_0.sav", Some("savegame_0")), None); // Versioned .sav is non-canonical assert_eq!( corruption_check("savegame_0_9.sav", Some("savegame_0")), @@ -456,7 +540,9 @@ mod tests { fn dump_all_samples() { use chrono::TimeZone; let dir = std::path::Path::new("samples"); - let Ok(entries) = std::fs::read_dir(dir) else { return }; + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; let mut files: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| { @@ -474,27 +560,54 @@ mod tests { let mb = b.metadata().ok().and_then(|m| m.modified().ok()); sa.cmp(&sb).then_with(|| mb.cmp(&ma)) }); - println!("\n{:<8} {:<26} {:<6} {:>7} {:<19} {:<28}", "", "Display Name", "Type", "Size", "Date", "File"); + println!( + "\n{:<8} {:<26} {:<6} {:>7} {:<19} {:<28}", + "", "Display Name", "Type", "Size", "Date", "File" + ); println!("{}", "-".repeat(115)); let mut seen = std::collections::HashSet::new(); for entry in &files { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - let mtime = entry.metadata().ok().and_then(|m| m.modified().ok()) - .and_then(|t| { let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); chrono::Local.timestamp_opt(s as i64,0).single() }) + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| { + let s = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + chrono::Local.timestamp_opt(s as i64, 0).single() + }) .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string()); let meta = extract_metadata(&path).ok(); - let slot = meta.as_ref().and_then(|m| m.slot_name.clone()) + let slot = meta + .as_ref() + .and_then(|m| m.slot_name.clone()) .unwrap_or_else(|| derive_slot_from_filename(&name).unwrap_or_else(|| "?".into())); - let display = meta.as_ref().and_then(|m| m.display_name.clone()).unwrap_or_else(|| "(unnamed)".into()); + let display = meta + .as_ref() + .and_then(|m| m.display_name.clone()) + .unwrap_or_else(|| "(unnamed)".into()); let online = meta.map(|m| m.is_online).unwrap_or(false); let num = slot.strip_prefix("savegame_").unwrap_or(&slot); let first = seen.insert(slot.clone()); - let label = if first { format!("Slot {num}") } else { String::new() }; + let label = if first { + format!("Slot {num}") + } else { + String::new() + }; let typ = if online { "Online" } else { "Local" }; - let sz = if size < 1024 { format!("{size} B") } else if size < 1_048_576 { format!("{:.0} KB", size as f64 / 1024.0) } else { format!("{:.1} MB", size as f64 / 1_048_576.0) }; - println!("{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}", mtime.as_deref().unwrap_or("?")); + let sz = if size < 1024 { + format!("{size} B") + } else if size < 1_048_576 { + format!("{:.0} KB", size as f64 / 1024.0) + } else { + format!("{:.1} MB", size as f64 / 1_048_576.0) + }; + println!( + "{label:<8} {display:<26} {typ:<6} {sz:>7} {:<19} {name:<28}", + mtime.as_deref().unwrap_or("?") + ); } println!(); } @@ -503,7 +616,9 @@ mod tests { /// Test helper — dump full GVAS metadata for a sample file. fn print_full_meta() { let p = Path::new("samples/savegame_1.sav"); - if !p.exists() { return; } + if !p.exists() { + return; + } let m = extract_full_metadata(p).unwrap(); println!("slot: {:?}", m.slot_name); println!("display: {:?}", m.display_name); diff --git a/src/main.rs b/src/main.rs index e7ef4c3..fb56e46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,6 @@ //! //! MIT License. Not affiliated with Unknown Worlds Entertainment or KRAFTON. - - mod config; mod discovery; mod guard; @@ -19,7 +17,10 @@ mod tui; use anyhow::Result; use chrono::TimeZone; use crossterm::{ - event::{self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEventKind, + }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -57,7 +58,12 @@ fn main() -> Result<()> { // ── setup terminal ───────────────────────────────────────────────── enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?; + execute!( + stdout, + EnterAlternateScreen, + EnableBracketedPaste, + EnableMouseCapture + )?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -87,7 +93,10 @@ struct App { impl App { fn new() -> Result { let log_path = guard::log_path(); - let tui_state = tui::AppState { version: VERSION.to_string(), ..Default::default() }; + let tui_state = tui::AppState { + version: VERSION.to_string(), + ..Default::default() + }; Ok(Self { log_path, save_folder: None, @@ -143,7 +152,10 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { let mut app = App::new()?; // Reminder — the user should close the game before using the tool - ok_dialog(terminal, &app, "Before You Begin", + ok_dialog( + terminal, + &app, + "Before You Begin", "Please close Subnautica 2 before using NotAlterra.\n\ \n\ The game holds file locks on your save files while active.\n\ @@ -160,7 +172,12 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { // Migrate old transaction.log into logs/ directory if guard::migrate_old_log() { - guard::log_action("MIGRATE", "old transaction.log moved to logs/", "OK", &app.log_path)?; + guard::log_action( + "MIGRATE", + "old transaction.log moved to logs/", + "OK", + &app.log_path, + )?; migrated_something = true; } @@ -180,7 +197,10 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { // Notify the user about completed migrations if migrated_something { - app.set_status("Data migrated from previous version. Old files remain — delete manually if desired.", tui::StatusStyle::Info); + app.set_status( + "Data migrated from previous version. Old files remain — delete manually if desired.", + tui::StatusStyle::Info, + ); } // Quick check of common save locations (current user only, no profile scans) @@ -210,7 +230,7 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { // Disclaimer flow if !crate::config::disclaimer_accepted() { match run_disclaimer(terminal, &mut app)? { - Some(true) => {} // accepted + Some(true) => {} // accepted _ => return Ok(()), // declined or cancelled on first launch → exit } } @@ -233,48 +253,54 @@ fn run_app(terminal: &mut Terminal) -> Result<()> { 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 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)); + if key.kind == KeyEventKind::Release { + continue; } - KeyCode::Down => { - 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)?, - 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(()); + match key.code { + KeyCode::Up => { + let mut i = menu_state.selected().unwrap_or(1); + loop { + i = i.saturating_sub(1); + if !SKIP.contains(&i) || i == 0 { + break; } } - 10 => return Ok(()), // Exit - _ => {} + menu_state.select(Some(i)); } - app.clear_status(); + KeyCode::Down => { + 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)?, + 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(()); + } + } + 10 => return Ok(()), // Exit + _ => {} + } + app.clear_status(); + } + KeyCode::Esc => { + return Ok(()); + } + _ => {} } - KeyCode::Esc => { - return Ok(()); - } - _ => {} - } } } } @@ -295,7 +321,9 @@ fn run_disclaimer(terminal: &mut Terminal, app: &mut App) -> Resu })?; if let Some(key) = poll_key(250)? { match key.code { - KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => selected_yes = !selected_yes, + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => { + selected_yes = !selected_yes + } KeyCode::Char('y') | KeyCode::Char('Y') => { guard::log_action("LICENSE", "accepted", "OK", &app.log_path)?; crate::config::accept_disclaimer()?; @@ -323,15 +351,13 @@ fn run_disclaimer(terminal: &mut Terminal, app: &mut App) -> Resu } } - // ── menu actions ─────────────────────────────────────────────────────────── /// Open the input dialog for the user to type a save-folder path. /// Validates the path exists and contains .sav files before accepting it. fn action_set_save_folder(terminal: &mut Terminal, app: &mut App) -> Result<()> { - let mut input_state = tui::InputDialogState::new( - "Enter the path to your Subnautica 2 SaveGames folder:", - ); + let mut input_state = + tui::InputDialogState::new("Enter the path to your Subnautica 2 SaveGames folder:"); let mut ok_selected = true; loop { @@ -342,13 +368,17 @@ fn action_set_save_folder(terminal: &mut Terminal, app: &mut App) if crossterm::event::poll(std::time::Duration::from_millis(250))? { match crossterm::event::read()? { Event::Key(key) => { - if key.kind == KeyEventKind::Release { continue; } + if key.kind == KeyEventKind::Release { + continue; + } match key.code { KeyCode::Enter => { if ok_selected && !input_state.input.is_empty() { // Sanitize: strip control characters to prevent // config.ini injection and log forgery. - let sanitized: String = input_state.input.chars() + let sanitized: String = input_state + .input + .chars() .filter(|c| !c.is_control()) .collect(); let candidate = discovery::validate_custom_path(&sanitized); @@ -365,13 +395,16 @@ fn action_set_save_folder(terminal: &mut Terminal, app: &mut App) return Ok(()); } else { // Invalid path — show error and let them retry - ok_dialog(terminal, app, "Invalid Path", + ok_dialog( + terminal, + app, + "Invalid Path", "The path you entered does not exist or\n\ does not contain any .sav save files.\n\ \n\ Please enter the full path to your\n\ SaveGames folder (e.g.\n\ - /home/user/.../SaveGames)." + /home/user/.../SaveGames).", )?; input_state.reset(); ok_selected = true; @@ -421,10 +454,8 @@ fn action_set_save_folder(terminal: &mut Terminal, app: &mut App) fn action_set_backup_location(terminal: &mut Terminal, 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:", - ); + .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; @@ -437,16 +468,23 @@ fn action_set_backup_location(terminal: &mut Terminal, app: &mut if crossterm::event::poll(std::time::Duration::from_millis(250))? { match crossterm::event::read()? { Event::Key(key) => { - if key.kind == KeyEventKind::Release { continue; } + 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() + 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()); + 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), @@ -525,37 +563,34 @@ fn action_recover_bak(terminal: &mut Terminal, 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 = std::collections::HashSet::new(); - let header = format!(" {:<8} {:<24} {}", - "Slot", "Description", "Date"); + let header = format!(" {:<8} {:<24} {}", "Slot", "Description", "Date"); let mut items: Vec = vec![header, String::new()]; - items.extend(bak_summaries - .iter() - .map(|s| { - let num = slot_number(&s.slot); - let first = labelled.insert(s.slot.clone()); - let label_col = if first { format!("Slot {num}") } else { String::new() }; - let name = s.display_name.as_deref().unwrap_or("(unnamed)"); - let name_col = if name.len() > 24 { - format!("{}…", &name[..23]) - } else { - name.to_string() - }; - let date = s.mtime.as_deref().unwrap_or("?"); - format!( - " {:<8} {:<24} {}", - label_col, - name_col, - date, - ) - }) - .collect::>()); + items.extend( + bak_summaries + .iter() + .map(|s| { + let num = slot_number(&s.slot); + let first = labelled.insert(s.slot.clone()); + let label_col = if first { + format!("Slot {num}") + } else { + String::new() + }; + let name = s.display_name.as_deref().unwrap_or("(unnamed)"); + let name_col = if name.len() > 24 { + format!("{}…", &name[..23]) + } else { + name.to_string() + }; + let date = s.mtime.as_deref().unwrap_or("?"); + format!(" {:<8} {:<24} {}", label_col, name_col, date,) + }) + .collect::>(), + ); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); // Filenames for the info bar, and descriptions - let filenames: Vec = bak_summaries - .iter() - .map(|s| s.filename.clone()) - .collect(); + let filenames: Vec = bak_summaries.iter().map(|s| s.filename.clone()).collect(); let descs: Vec = bak_summaries .iter() .map(|_| "Restore this backup to its canonical .sav file".to_string()) @@ -570,7 +605,7 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> fn ensure_meta( idx: usize, bak: &[ops::BakFileSummary], - cache: &mut Vec>, + cache: &mut [Option], ) { if idx < cache.len() && cache[idx].is_none() { cache[idx] = crate::gvas::extract_full_metadata(&bak[idx].path).ok(); @@ -591,14 +626,43 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> 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()), + ( + "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())), + ( + "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 @@ -628,8 +692,6 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> 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() }; @@ -646,7 +708,7 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> ); })?; if let Some(key) = poll_key(250)? { - match key.code { + match key.code { KeyCode::Up => { let i = state.selected().unwrap_or(0); state.select(Some(i.saturating_sub(1))); @@ -657,7 +719,9 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> } KeyCode::Enter => { let sel = state.selected().unwrap_or(2); - if sel < 2 { continue; } // header + blank + if sel < 2 { + continue; + } // header + blank let idx = sel.saturating_sub(2); let chosen = &bak_summaries[idx]; let target = derive_target_sav(&chosen.filename); @@ -676,15 +740,27 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> let disp = meta.and_then(|m| m.display_name); (sz, mt, online, disp) }) - } else { None }; + } else { + None + }; // Mode change warning let mode_entry = target_meta.as_ref().and_then(|(_, _, live, _)| { if *live != chosen.is_online { - let from = if *live { "Multiplayer" } else { "Single Player" }; - let to = if chosen.is_online { "Multiplayer" } else { "Single Player" }; + let from = if *live { + "Multiplayer" + } else { + "Single Player" + }; + let to = if chosen.is_online { + "Multiplayer" + } else { + "Single Player" + }; Some(("⚠ Mode change", format!("{from} → {to}"))) - } else { None } + } else { + None + } }); // Name change warning let name_entry = target_meta.as_ref().and_then(|(_, _, _, live_name)| { @@ -698,15 +774,30 @@ fn action_recover_bak(terminal: &mut Terminal, app: &mut App) -> }); // Build details - let src_line = format!("{} {} {}", chosen.filename, format_size(chosen.size), chosen.mtime.as_deref().unwrap_or("?")); + let src_line = format!( + "{} {} {}", + chosen.filename, + format_size(chosen.size), + chosen.mtime.as_deref().unwrap_or("?") + ); let mut details = vec![ ("Slot", chosen.slot.as_str()), - ("Name", chosen.display_name.as_deref().unwrap_or("(unnamed)")), + ( + "Name", + chosen.display_name.as_deref().unwrap_or("(unnamed)"), + ), ("Backup", src_line.as_str()), ]; let tgt_line: String; if let Some((sz, mt, _, _)) = &target_meta { - tgt_line = format!("{} {} {}", target, format_size(*sz), mt.map(|d| d.format("%Y-%b-%d %H:%M").to_string()).as_deref().unwrap_or("?")); + tgt_line = format!( + "{} {} {}", + target, + format_size(*sz), + mt.map(|d| d.format("%Y-%b-%d %H:%M").to_string()) + .as_deref() + .unwrap_or("?") + ); details.push(("Replace", tgt_line.as_str())); } else { details.push(("Create", target.as_str())); @@ -778,7 +869,11 @@ fn action_create_backup(terminal: &mut Terminal, app: &mut App) - match ops::create_full_backup(&save_folder) { Ok(result) => { app.set_spinner(false); - let verified = if result.verified { "verified" } else { "unverified" }; + let verified = if result.verified { + "verified" + } else { + "unverified" + }; let msg = format!( "{} files, {} — backup {}", result.files_copied, @@ -791,7 +886,12 @@ fn action_create_backup(terminal: &mut Terminal, app: &mut App) - Err(e) => { app.set_spinner(false); let msg = format!("Backup failed: {e}"); - guard::log_action("MANUAL_BAK", &guard::sanitize_path(&save_folder.display().to_string()), &format!("FAILED: {e}"), &app.log_path)?; + guard::log_action( + "MANUAL_BAK", + &guard::sanitize_path(&save_folder.display().to_string()), + &format!("FAILED: {e}"), + &app.log_path, + )?; ok_dialog(terminal, app, "Backup Failed", &msg)?; } } @@ -841,18 +941,47 @@ fn action_restore_backup(terminal: &mut Terminal, app: &mut App) if !has_existing_backup(app) { restore_details.push(("⚠ No backup", "create a full backup first")); } - let accepted = confirm_modal(terminal, app, "Confirm Restore", &restore_details)?; + let accepted = + confirm_modal(terminal, app, "Confirm Restore", &restore_details)?; if accepted { - guard::log_action("AUTO_BAK", &format!("pre-restore → {}", guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?; + guard::log_action( + "AUTO_BAK", + &format!( + "pre-restore → {}", + guard::sanitize_path(&save_folder.display().to_string()) + ), + "OK", + &app.log_path, + )?; match ops::restore_full_backup(chosen, &save_folder) { Ok(n) => { - app.set_status(&format!("{n} save files restored."), tui::StatusStyle::Success); - guard::log_action("RESTORE", &format!("{} → {}", name, guard::sanitize_path(&save_folder.display().to_string())), "OK", &app.log_path)?; + app.set_status( + &format!("{n} save files restored."), + tui::StatusStyle::Success, + ); + guard::log_action( + "RESTORE", + &format!( + "{} → {}", + name, + guard::sanitize_path(&save_folder.display().to_string()) + ), + "OK", + &app.log_path, + )?; } Err(e) => { - app.set_status(&format!("Restore failed: {e}"), tui::StatusStyle::Error); - guard::log_action("RESTORE", &name, &format!("FAILED: {e}"), &app.log_path)?; + app.set_status( + &format!("Restore failed: {e}"), + tui::StatusStyle::Error, + ); + guard::log_action( + "RESTORE", + &name, + &format!("FAILED: {e}"), + &app.log_path, + )?; } } } @@ -894,15 +1023,24 @@ fn run_ini_submenu(terminal: &mut Terminal, app: &mut App) -> Res loop { terminal.draw(|f| { - tui::draw_sub_menu(f, &app.tui_state, "Config (.ini) Management", &items, &descs, &mut state); + tui::draw_sub_menu( + f, + &app.tui_state, + "Config (.ini) Management", + &items, + &descs, + &mut state, + ); })?; if let Some(key) = poll_key(250)? { - match key.code { + match key.code { KeyCode::Up => { let mut i = state.selected().unwrap_or(1); loop { i = i.saturating_sub(1); - if !INI_SKIP.contains(&i) || i == 0 { break; } + if !INI_SKIP.contains(&i) || i == 0 { + break; + } } state.select(Some(i)); } @@ -910,7 +1048,9 @@ fn run_ini_submenu(terminal: &mut Terminal, app: &mut App) -> Res let mut i = state.selected().unwrap_or(0); loop { i = (i + 1).min(ini_max); - if !INI_SKIP.contains(&i) || i == ini_max { break; } + if !INI_SKIP.contains(&i) || i == ini_max { + break; + } } state.select(Some(i)); } @@ -926,7 +1066,7 @@ fn run_ini_submenu(terminal: &mut Terminal, app: &mut App) -> Res } KeyCode::Esc => break, _ => {} - } + } } } @@ -942,15 +1082,34 @@ fn ini_backup_action( ) -> Result<()> { match ops::backup_ini_files(ini_path) { Ok(result) => { - let verified = if result.verified { "verified" } else { "unverified" }; + let verified = if result.verified { + "verified" + } else { + "unverified" + }; app.set_status( - &format!("Config backup created: {} files ({})", result.files_copied, verified), + &format!( + "Config backup created: {} files ({})", + result.files_copied, verified + ), tui::StatusStyle::Success, ); - guard::log_action("CONFIG_BAK", &guard::sanitize_path(&result.dest_path.display().to_string()), "OK", &app.log_path)?; + guard::log_action( + "CONFIG_BAK", + &guard::sanitize_path(&result.dest_path.display().to_string()), + "OK", + &app.log_path, + )?; refresh_stats(&mut app.tui_state, app.save_folder.as_deref()); - let verified = if result.verified { "verified" } else { "unverified" }; - let msg = format!("{} .ini file(s) backed up ({verified}).", result.files_copied); + let verified = if result.verified { + "verified" + } else { + "unverified" + }; + let msg = format!( + "{} .ini file(s) backed up ({verified}).", + result.files_copied + ); ok_dialog(terminal, app, ".ini Backup Complete", &msg)?; } Err(e) => { @@ -970,7 +1129,12 @@ fn ini_restore_action( ) -> Result<()> { let backups = ops::list_ini_backups(); if backups.is_empty() { - ok_dialog(terminal, app, "No .ini Backups", "No .ini backups found.\nUse 'Backup .ini files' first.")?; + ok_dialog( + terminal, + app, + "No .ini Backups", + "No .ini backups found.\nUse 'Backup .ini files' first.", + )?; return Ok(()); } @@ -1003,10 +1167,23 @@ fn ini_restore_action( let idx = state.selected().unwrap_or(0); let chosen = &backups[idx]; - guard::log_action("AUTO_BAK", &format!("ini pre-restore → {}", guard::sanitize_path(&ini_path.display().to_string())), "OK", &app.log_path)?; + guard::log_action( + "AUTO_BAK", + &format!( + "ini pre-restore → {}", + guard::sanitize_path(&ini_path.display().to_string()) + ), + "OK", + &app.log_path, + )?; match ops::restore_ini_files(chosen, ini_path) { Ok(n) => { - guard::log_action("CONFIG_RESTORE", &guard::sanitize_path(&chosen.display().to_string()), "OK", &app.log_path)?; + guard::log_action( + "CONFIG_RESTORE", + &guard::sanitize_path(&chosen.display().to_string()), + "OK", + &app.log_path, + )?; let msg = format!("{n} .ini file(s) restored."); ok_dialog(terminal, app, ".ini Restore Complete", &msg)?; } @@ -1034,22 +1211,29 @@ fn ini_delete_action( ) -> Result<()> { let config_dir = crate::config::backups_config_dir(); let has_backup = config_dir.exists() - && std::fs::read_dir(&config_dir) - .map(|entries| entries.flatten().any(|e| { - let file_name = e.file_name(); - let name = file_name.to_string_lossy(); - name.starts_with("ini_backup_") - && e.path().is_dir() - && std::fs::read_dir(e.path()).is_ok_and(|mut d| { - d.any(|f| f.ok().is_some_and(|f| { - f.file_name().to_string_lossy().ends_with(".ini") - })) + && std::fs::read_dir(&config_dir) + .map(|entries| { + entries.flatten().any(|e| { + let file_name = e.file_name(); + let name = file_name.to_string_lossy(); + name.starts_with("ini_backup_") + && e.path().is_dir() + && std::fs::read_dir(e.path()).is_ok_and(|mut d| { + d.any(|f| { + f.ok().is_some_and(|f| { + f.file_name().to_string_lossy().ends_with(".ini") + }) + }) + }) }) - })) - .unwrap_or(false); + }) + .unwrap_or(false); if !has_backup { - ok_dialog(terminal, app, "No Backup Found", + ok_dialog( + terminal, + app, + "No Backup Found", "No .ini backup directory found.\n\ \n\ Run \"Backup .ini files\" first to create a snapshot\n\ @@ -1060,8 +1244,15 @@ fn ini_delete_action( match ops::delete_ini_files(ini_path) { Ok(n) => { - let msg = format!("Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch."); - guard::log_action("CONFIG_DEL", &guard::sanitize_path(&ini_path.display().to_string()), "OK", &app.log_path)?; + let msg = format!( + "Deleted {n} .ini file(s).\nThe game will regenerate defaults on next launch." + ); + guard::log_action( + "CONFIG_DEL", + &guard::sanitize_path(&ini_path.display().to_string()), + "OK", + &app.log_path, + )?; ok_dialog(terminal, app, ".ini Delete Complete", &msg)?; } Err(e) => { @@ -1089,20 +1280,30 @@ fn action_inspect_saves(terminal: &mut Terminal, app: &mut App) - files.sort_by_key(|e| e.file_name()); let mut labelled = std::collections::HashSet::new(); - let mut items: Vec = files.iter().map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - let slot = crate::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()); - let num = slot_number(&slot); - let first = labelled.insert(slot.clone()); - let label = if first { format!("Slot {num}") } else { String::new() }; - let sz = e.metadata().map(|m| m.len()).unwrap_or(0); - format!(" {:<8} {:<28} {:>7}", label, name, format_size(sz)) - }).collect(); + let mut items: Vec = files + .iter() + .map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let slot = crate::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()); + let num = slot_number(&slot); + let first = labelled.insert(slot.clone()); + let label = if first { + format!("Slot {num}") + } else { + String::new() + }; + let sz = e.metadata().map(|m| m.len()).unwrap_or(0); + format!(" {:<8} {:<28} {:>7}", label, name, format_size(sz)) + }) + .collect(); let header = format!(" {:<8} {:<28} {:>7}", "Slot", "Filename", "Size"); items.insert(0, header); items.insert(1, String::new()); let item_refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect(); - let filenames: Vec = files.iter().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + let filenames: Vec = files + .iter() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); let descs = vec!["Press Enter to view full GVAS metadata"; files.len()]; let desc_refs: Vec<&str> = descs.to_vec(); let mut state = ListState::default().with_selected(Some(2)); // skip header + blank @@ -1112,58 +1313,126 @@ fn action_inspect_saves(terminal: &mut Terminal, app: &mut App) - state.select(Some(i)); let selected_info = filenames.get(i.saturating_sub(2)).map(|s| s.as_str()); terminal.draw(|f| { - tui::draw_picker_with_info(f, &app.tui_state, &item_refs, &desc_refs, &mut state, selected_info); + tui::draw_picker_with_info( + f, + &app.tui_state, + &item_refs, + &desc_refs, + &mut state, + selected_info, + ); })?; 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))); } - KeyCode::Down => { let i = state.selected().unwrap_or(0); state.select(Some((i+1).min(items.len().saturating_sub(1)))); } - KeyCode::Enter => { - let sel = state.selected().unwrap_or(2); - if sel < 2 { continue; } - let idx = sel.saturating_sub(2); - let path = files[idx].path(); - match crate::gvas::extract_full_metadata(&path) { - Ok(meta) => { - let dim = Style::default().fg(Color::DarkGray); - let val = Style::default().fg(Color::White); - let hl = Style::default().fg(Color::Cyan); - let mut lines: Vec = vec![ - Line::from(Span::styled(filenames[idx].clone(), hl.add_modifier(Modifier::BOLD))), - Line::from(""), - ]; - let fields: Vec<(&str, &str, String)> = vec![ - ("SlotName", ":", meta.slot_name.as_deref().unwrap_or("?").into()), - ("DisplayName", ":", meta.display_name.as_deref().unwrap_or("(unnamed)").into()), - ("Game Type", ":", (if meta.is_online { "Multiplayer" } else { "Single Player" }).into()), - ("Was Multi", ":", (if meta.was_multiplayer { "yes" } else { "no" }).into()), - ("GameMode", ":", meta.game_mode.as_deref().unwrap_or("?").into()), - ("Level", ":", meta.level_name.as_deref().unwrap_or("?").into()), - ("Build", ":", meta.build_number.map_or("?".into(), |n| n.to_string())), - ("Branch", ":", meta.build_branch.as_deref().unwrap_or("?").into()), - ("Saves", ":", meta.saves_count.map_or("?".into(), |n| n.to_string())), - ("Ver", ":", meta.latest_version.map_or("?".into(), |n| n.to_string())), - ("DataVer", ":", meta.data_version.map_or("?".into(), |n| n.to_string())), - ("Playtime", ":", format_playtime(meta.playtime_seconds)), - ]; - let max_label: usize = fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8); - for (key, sep, value) in fields { - let padded = format!("{: { + let i = state.selected().unwrap_or(0); + state.select(Some(i.saturating_sub(1))); + } + KeyCode::Down => { + let i = state.selected().unwrap_or(0); + state.select(Some((i + 1).min(items.len().saturating_sub(1)))); + } + KeyCode::Enter => { + let sel = state.selected().unwrap_or(2); + if sel < 2 { + continue; } - Err(e) => { - ok_dialog(terminal, app, "Parse Error", &format!("{e}"))?; + let idx = sel.saturating_sub(2); + let path = files[idx].path(); + match crate::gvas::extract_full_metadata(&path) { + Ok(meta) => { + let dim = Style::default().fg(Color::DarkGray); + let val = Style::default().fg(Color::White); + let hl = Style::default().fg(Color::Cyan); + let mut lines: Vec = vec![ + Line::from(Span::styled( + filenames[idx].clone(), + hl.add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + let fields: Vec<(&str, &str, String)> = vec![ + ( + "SlotName", + ":", + meta.slot_name.as_deref().unwrap_or("?").into(), + ), + ( + "DisplayName", + ":", + meta.display_name.as_deref().unwrap_or("(unnamed)").into(), + ), + ( + "Game Type", + ":", + (if meta.is_online { + "Multiplayer" + } else { + "Single Player" + }) + .into(), + ), + ( + "Was Multi", + ":", + (if meta.was_multiplayer { "yes" } else { "no" }).into(), + ), + ( + "GameMode", + ":", + meta.game_mode.as_deref().unwrap_or("?").into(), + ), + ( + "Level", + ":", + meta.level_name.as_deref().unwrap_or("?").into(), + ), + ( + "Build", + ":", + meta.build_number.map_or("?".into(), |n| n.to_string()), + ), + ( + "Branch", + ":", + meta.build_branch.as_deref().unwrap_or("?").into(), + ), + ( + "Saves", + ":", + meta.saves_count.map_or("?".into(), |n| n.to_string()), + ), + ( + "Ver", + ":", + meta.latest_version.map_or("?".into(), |n| n.to_string()), + ), + ( + "DataVer", + ":", + meta.data_version.map_or("?".into(), |n| n.to_string()), + ), + ("Playtime", ":", format_playtime(meta.playtime_seconds)), + ]; + let max_label: usize = + fields.iter().map(|(k, _, _)| k.len()).max().unwrap_or(8); + for (key, sep, value) in fields { + let padded = format!("{: { + ok_dialog(terminal, app, "Parse Error", &format!("{e}"))?; + } } } + KeyCode::Esc => break, + _ => {} } - KeyCode::Esc => break, - _ => {} - } } } Ok(()) @@ -1192,9 +1461,7 @@ fn ensure_save_folder(_terminal: &mut Terminal, app: &mut App) -> return Ok(sf.clone()); } } - anyhow::bail!( - "No save folder set. Use 'Set save folder' from the main menu first." - ) + anyhow::bail!("No save folder set. Use 'Set save folder' from the main menu first.") } /// Derive the Config\Windows path from the save folder. @@ -1219,7 +1486,12 @@ fn slot_number(slot: &str) -> String { /// Display a dialog with styled content lines (colors, bold). Accepts /// `Line` slices — use for metadata displays, help text, or any content /// that needs inline formatting. Press Enter or Space to dismiss. -fn ok_dialog_styled(terminal: &mut Terminal, app: &App, title: &str, lines: &[Line]) -> Result<()> { +fn ok_dialog_styled( + terminal: &mut Terminal, + app: &App, + title: &str, + lines: &[Line], +) -> Result<()> { loop { terminal.draw(|f| tui::draw_ok_dialog_styled(f, &app.tui_state, title, lines))?; if let Some(key) = poll_key(250)? { @@ -1233,7 +1505,12 @@ fn ok_dialog_styled(terminal: &mut Terminal, app: &App, title: &s /// 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`. -fn ok_dialog(terminal: &mut Terminal, app: &App, title: &str, msg: &str) -> Result<()> { +fn ok_dialog( + terminal: &mut Terminal, + app: &App, + title: &str, + msg: &str, +) -> Result<()> { loop { terminal.draw(|f| tui::draw_ok_dialog(f, &app.tui_state, title, msg))?; if let Some(key) = poll_key(250)? { @@ -1248,7 +1525,10 @@ fn ok_dialog(terminal: &mut Terminal, app: &App, title: &str, msg /// Used to gate destructive operations behind a backup requirement. fn has_existing_backup(_app: &App) -> bool { let root = crate::config::backups_saves_dir(); - root.exists() && std::fs::read_dir(&root).is_ok_and(|mut d| d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().ends_with(".tar.gz")))) + root.exists() + && std::fs::read_dir(&root).is_ok_and(|mut d| { + d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))) + }) } /// Gate: warn the user if no full backup exists yet. Returns `false` if @@ -1257,7 +1537,10 @@ fn require_backup(terminal: &mut Terminal, app: &mut App) -> Resu if has_existing_backup(app) { return Ok(true); } - app.set_status("No backup found — create a full backup before destructive actions.", tui::StatusStyle::Error); + app.set_status( + "No backup found — create a full backup before destructive actions.", + tui::StatusStyle::Error, + ); wait_for_key(terminal, app)?; Ok(false) } @@ -1288,15 +1571,15 @@ fn confirm_modal( tui::draw_confirm_popup(f, &app.tui_state, title, details, selected_yes); })?; if let Some(key) = poll_key(250)? { - match key.code { - KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => { - selected_yes = !selected_yes; + match key.code { + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => { + selected_yes = !selected_yes; + } + KeyCode::Char('y') | KeyCode::Char('Y') => return Ok(true), + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => return Ok(false), + KeyCode::Enter => return Ok(selected_yes), + _ => {} } - KeyCode::Char('y') | KeyCode::Char('Y') => return Ok(true), - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => return Ok(false), - KeyCode::Enter => return Ok(selected_yes), - _ => {} - } } } } @@ -1330,11 +1613,15 @@ fn wait_for_key(terminal: &mut Terminal, app: &App) -> Result<()> "Press any key to return to menu…", Style::default().fg(Color::DarkGray), ); - let p = Paragraph::new(prompt_span) - .alignment(Alignment::Center); + let p = Paragraph::new(prompt_span).alignment(Alignment::Center); f.render_widget(p, centered_bottom(f.area())); // Whale at bottom - let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 }; + let bar = Rect { + x: 0, + y: f.area().height.saturating_sub(1), + width: f.area().width, + height: 1, + }; tui::draw_whale_separator(f, bar, &app.tui_state); })?; if let Event::Key(_) = event::read()? { diff --git a/src/ops.rs b/src/ops.rs index 63e950c..7692527 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -35,18 +35,18 @@ pub struct RecoveryResult { // ── .sav recovery from .bak ──────────────────────────────────────────────── -pub fn recover_bak_to_sav( - save_folder: &Path, - bak_filename: &str, -) -> Result { +pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result { let bak_path = save_folder.join(bak_filename); if !bak_path.exists() { anyhow::bail!("backup file not found: {}", bak_path.display()); } - let meta = fs::metadata(&bak_path) - .with_context(|| format!("cannot read {}", bak_path.display()))?; + let meta = + fs::metadata(&bak_path).with_context(|| format!("cannot read {}", bak_path.display()))?; if meta.len() < 1024 { - anyhow::bail!("backup file too small ({} bytes) — aborting restore", meta.len()); + anyhow::bail!( + "backup file too small ({} bytes) — aborting restore", + meta.len() + ); } let slot = derive_slot_from_filename(bak_filename) .ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?; @@ -55,14 +55,27 @@ pub fn recover_bak_to_sav( let mut old_saved_as = None; if target_path.exists() { let old_path = save_folder.join(format!("{target_name}.old")); - fs::rename(&target_path, &old_path) - .with_context(|| format!("cannot rename {} → {}", target_path.display(), old_path.display()))?; + fs::rename(&target_path, &old_path).with_context(|| { + format!( + "cannot rename {} → {}", + target_path.display(), + old_path.display() + ) + })?; old_saved_as = Some(format!("{target_name}.old")); } fs::copy(&bak_path, &target_path).with_context(|| { - format!("cannot copy {} → {}", bak_path.display(), target_path.display()) + format!( + "cannot copy {} → {}", + bak_path.display(), + target_path.display() + ) })?; - Ok(RecoveryResult { source: bak_filename.to_string(), target: target_name, old_saved_as }) + Ok(RecoveryResult { + source: bak_filename.to_string(), + target: target_name, + old_saved_as, + }) } // ── tar.gz helpers ───────────────────────────────────────────────────────── @@ -95,7 +108,6 @@ fn create_tar_gz( }) .collect(); - // Write a manifest entry first let mut manifest = String::new(); for entry in &entries { @@ -121,12 +133,14 @@ fn create_tar_gz( let data = fs::read(&src_path) .with_context(|| format!("failed to read {}", src_path.display()))?; let mut header = tar::Header::new_gnu(); - header.set_path(&name) + header + .set_path(&name) .with_context(|| format!("failed to set path '{name}' in tar header"))?; header.set_size(data.len() as u64); header.set_mode(0o644); // owner read/write, group/other read header.set_cksum(); - tar_builder.append(&header, &data[..]) + tar_builder + .append(&header, &data[..]) .with_context(|| format!("failed to append '{name}' to tar archive"))?; count += 1; total += size; @@ -191,14 +205,24 @@ pub fn create_full_backup(save_folder: &Path) -> Result { let backup_dir = crate::config::backups_saves_dir(); let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?; let verified = path.exists(); - Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified }) + Ok(BackupResult { + files_copied: count, + total_size: total, + dest_path: path, + verified, + }) } pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result { // Pre-restore safety: back up current saves let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f"); let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz")); - if let Err(_e) = create_tar_gz(save_folder, &crate::config::backups_saves_dir(), "savegame_", "pre_restore") { + if let Err(_e) = create_tar_gz( + save_folder, + &crate::config::backups_saves_dir(), + "savegame_", + "pre_restore", + ) { // pre-restore failure is non-fatal } let _ = pre_restore; @@ -224,7 +248,12 @@ pub fn backup_ini_files(config_path: &Path) -> Result { let backup_dir = crate::config::backups_config_dir(); let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?; let verified = path.exists(); - Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified }) + Ok(BackupResult { + files_copied: count, + total_size: total, + dest_path: path, + verified, + }) } pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result { @@ -269,7 +298,10 @@ pub fn list_bak_files(save_folder: &Path) -> Vec { files.sort_by(|a, b| { let ma = fs::metadata(a).ok(); let mb = fs::metadata(b).ok(); - match (ma.and_then(|m| m.modified().ok()), mb.and_then(|m| m.modified().ok())) { + match ( + ma.and_then(|m| m.modified().ok()), + mb.and_then(|m| m.modified().ok()), + ) { (Some(a), Some(b)) => b.cmp(&a), _ => std::cmp::Ordering::Equal, } @@ -306,19 +338,28 @@ pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec { let filename = entry.file_name().to_string_lossy().to_string(); let meta = fs::metadata(&path).ok(); let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); - let mtime = meta.as_ref() - .and_then(|m| m.modified().ok()) - .and_then(|t| { - let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); - Local.timestamp_opt(secs as i64, 0).single() - .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string()) - }); + let mtime = meta.as_ref().and_then(|m| m.modified().ok()).and_then(|t| { + let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Local + .timestamp_opt(secs as i64, 0) + .single() + .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string()) + }); let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into()); let meta = extract_metadata(&path).ok(); let display_name = meta.as_ref().and_then(|m| m.display_name.clone()); let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false); let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds); - files.push(BakFileSummary { path, filename, slot, display_name, is_online, size, mtime, playtime_seconds }); + files.push(BakFileSummary { + path, + filename, + slot, + display_name, + is_online, + size, + mtime, + playtime_seconds, + }); } files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime))); files @@ -361,9 +402,11 @@ pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) { let ini_has_backup = crate::config::backups_config_dir().exists() && fs::read_dir(crate::config::backups_config_dir()) - .map(|entries| entries.flatten().any(|e| { - e.file_name().to_string_lossy().ends_with(".tar.gz") - })) + .map(|entries| { + entries + .flatten() + .any(|e| e.file_name().to_string_lossy().ends_with(".tar.gz")) + }) .unwrap_or(false); let _ = crate::config::backups_saves_dir(); // ensure dir exists @@ -399,19 +442,25 @@ fn migrate_backups_from(old_root: PathBuf) -> Result { if dir_name.starts_with("notalterra_copy_") { // Migrate old save backups → backups/saves/ let has_saves = fs::read_dir(&path) - .map(|e| e.flatten().any(|f| { - f.file_name().to_string_lossy().starts_with("savegame_") - })) + .map(|e| { + e.flatten() + .any(|f| f.file_name().to_string_lossy().starts_with("savegame_")) + }) .unwrap_or(false); if !has_saves { continue; } let backup_dir = crate::config::backups_saves_dir(); - match create_tar_gz(&path, &backup_dir, "savegame_", &format!("migrated_{dir_name}")) { + match create_tar_gz( + &path, + &backup_dir, + "savegame_", + &format!("migrated_{dir_name}"), + ) { Ok((_count, _size, archive_path)) if archive_path.exists() => { migrated += 1; } - Ok(_) => {}, + Ok(_) => {} Err(e) => { eprintln!("migration warning: failed to archive {:?}: {}", path, e); } @@ -423,7 +472,7 @@ fn migrate_backups_from(old_root: PathBuf) -> Result { Ok((_count, _size, archive_path)) if archive_path.exists() => { migrated += 1; } - Ok(_) => {}, + Ok(_) => {} Err(e) => { eprintln!("migration warning: failed to archive {:?}: {}", path, e); } @@ -454,19 +503,31 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); - create_old_backup(&old_root, "notalterra_copy_2025-01-01_120000", &["savegame_0.sav"]); - create_old_backup(&old_root, "notalterra_copy_2025-01-02_120000", &["savegame_0.sav", "savegame_1.sav"]); + create_old_backup( + &old_root, + "notalterra_copy_2025-01-01_120000", + &["savegame_0.sav"], + ); + create_old_backup( + &old_root, + "notalterra_copy_2025-01-02_120000", + &["savegame_0.sav", "savegame_1.sav"], + ); let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 2, "two old backups should be migrated"); // Verify archives exist in the shared backup directory let saves_dir = crate::config::backups_saves_dir(); - let archives: Vec<_> = fs::read_dir(&saves_dir).unwrap() + let archives: Vec<_> = fs::read_dir(&saves_dir) + .unwrap() .flatten() .filter(|e| e.file_name().to_string_lossy().contains("migrated_")) .collect(); - assert!(archives.len() >= 2, "at least 2 migrated archives should exist"); + assert!( + archives.len() >= 2, + "at least 2 migrated archives should exist" + ); } #[test] @@ -493,27 +554,45 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); - let _dir = create_old_backup(&old_root, "notalterra_copy_2026-01-01_120000", &["savegame_0.sav", "savegame_1.sav"]); + let _dir = create_old_backup( + &old_root, + "notalterra_copy_2026-01-01_120000", + &["savegame_0.sav", "savegame_1.sav"], + ); let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 1); // Find the migrated archive by matching the directory name let saves_dir = crate::config::backups_saves_dir(); - let archive: Option = fs::read_dir(&saves_dir).unwrap() + let archive: Option = fs::read_dir(&saves_dir) + .unwrap() .flatten() - .filter(|e| e.file_name().to_string_lossy().contains("migrated_notalterra_copy_2026-01-01")) + .filter(|e| { + e.file_name() + .to_string_lossy() + .contains("migrated_notalterra_copy_2026-01-01") + }) .map(|e| e.path()) .find(|_| true); - assert!(archive.is_some(), "migrated archive should exist for 2026-01-01"); + assert!( + archive.is_some(), + "migrated archive should exist for 2026-01-01" + ); // Extract to a temp dir and verify content let extract_dir = tmp.path().join("extracted"); fs::create_dir_all(&extract_dir).unwrap(); let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap(); assert_eq!(extracted, 2, "both save files should be restored"); - assert_eq!(fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(), "content-0"); - assert_eq!(fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(), "content-1"); + assert_eq!( + fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(), + "content-0" + ); + assert_eq!( + fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(), + "content-1" + ); } #[test] @@ -527,7 +606,10 @@ mod tests { fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap(); let count = migrate_backups_from(old_root.clone()).unwrap(); - assert_eq!(count, 1, "only the dir with save files and correct prefix should be migrated"); + assert_eq!( + count, 1, + "only the dir with save files and correct prefix should be migrated" + ); } #[test] diff --git a/src/tui.rs b/src/tui.rs index 8fb4244..05ab08b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -117,7 +117,12 @@ pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState) { /// 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 }; + 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)); @@ -133,24 +138,83 @@ pub fn draw_disclaimer_popup(f: &mut Frame, app: &AppState, selected_yes: bool) 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(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(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(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))), + 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 }); + 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 }); + 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. @@ -161,7 +225,12 @@ pub fn draw_confirm_popup( 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 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()); @@ -177,29 +246,57 @@ pub fn draw_confirm_popup( // Title f.render_widget( - Paragraph::new(Span::styled(title, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))) - .alignment(Alignment::Center), + 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 = 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(); + let detail_lines: Vec = 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 }, + 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 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(" "), @@ -207,11 +304,20 @@ pub fn draw_confirm_popup( ]); f.render_widget( Paragraph::new(buttons).alignment(Alignment::Center), - Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner }, + 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 }; + let bar = Rect { + x: 0, + y: f.area().height.saturating_sub(1), + width: f.area().width, + height: 1, + }; draw_whale_separator(f, bar, app); } @@ -219,22 +325,68 @@ pub fn draw_confirm_popup( /// Auto-sizes to fit content. Title is displayed in cyan, message in gray, /// whale separator at the bottom. Press Enter or Space to dismiss. 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 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)); + 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(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 }); + 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 }; + let bar = Rect { + x: 0, + y: f.area().height.saturating_sub(1), + width: f.area().width, + height: 1, + }; draw_whale_separator(f, bar, app); } @@ -242,21 +394,67 @@ pub fn draw_ok_dialog(f: &mut Frame, app: &AppState, title: &str, message: &str) /// (colors, bold) via [`Line`] slices. Use for metadata displays, help /// text, or any content that needs per-span styling. 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 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)); + 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 }); + 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 }; + let bar = Rect { + x: 0, + y: f.area().height.saturating_sub(1), + width: f.area().width, + height: 1, + }; draw_whale_separator(f, bar, app); } @@ -264,11 +462,21 @@ pub fn draw_ok_dialog_styled(f: &mut Frame, app: &AppState, title: &str, lines: /// Shrink a rectangle to the given absolute width and height, centered. /// Return a rectangle centered in `r` by the given width and height percentages. 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)]) + 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)]) + 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] } @@ -293,29 +501,28 @@ pub fn draw_sub_menu( )); f.render_widget(title_p, chunks[1]); - draw_select_list(f, chunks[2], items, descs, "↑/↓ navigate Enter select Esc back", state); + draw_select_list( + f, + chunks[2], + items, + descs, + "↑/↓ navigate Enter select Esc back", + state, + ); draw_status_bar(f, chunks[3], app); } /// Draw a full-screen text display with a "press any key" prompt at the /// bottom. Used for status messages during long operations (scanning, /// backing up) and for displaying scan results. -pub fn draw_text_screen( - f: &mut Frame, - app: &AppState, - lines: &[Line], - prompt: &str, -) { +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); + let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray))) + .alignment(Alignment::Center); f.render_widget(prompt_p, chunks[3]); } @@ -354,12 +561,13 @@ fn standard_layout(area: Rect, _menu_items: usize) -> Vec { 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 + Constraint::Length(3), // header + Constraint::Length(2), // dashboard + Constraint::Min(1), // menu (fills remaining) + Constraint::Length(1), // status bar ]) - .split(area).to_vec() + .split(area) + .to_vec() } /// Render the title bar with version information. @@ -371,14 +579,16 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) { let chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(20), - Constraint::Min(0), - ]) + .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::styled( + "NotAlterra", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), Span::raw(" "), Span::styled(app.version.clone(), Style::default().fg(Color::DarkGray)), ]); @@ -403,16 +613,35 @@ fn draw_header(f: &mut Frame, area: Rect, app: &AppState) { /// Render the status dashboard beneath the header. 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() }), + 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), + 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 }), + 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![ @@ -444,10 +673,7 @@ fn draw_select_list( let list_items: Vec = items .iter() - .map(|item| { - ListItem::new(Span::raw(*item)) - .style(Style::default()) - }) + .map(|item| ListItem::new(Span::raw(*item)).style(Style::default())) .collect(); let list = List::new(list_items) @@ -462,7 +688,10 @@ fn draw_select_list( 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_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}"), @@ -482,11 +711,8 @@ fn draw_select_list( // 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); + let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray))) + .alignment(Alignment::Right); f.render_widget( prompt_p, Rect { @@ -517,10 +743,7 @@ fn draw_select_list_with_info( let list_items: Vec = items .iter() - .map(|item| { - ListItem::new(Span::raw(*item)) - .style(Style::default()) - }) + .map(|item| ListItem::new(Span::raw(*item)).style(Style::default())) .collect(); let list = List::new(list_items) @@ -537,7 +760,10 @@ fn draw_select_list_with_info( // 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_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}"), @@ -574,11 +800,8 @@ fn draw_select_list_with_info( // 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); + let prompt_p = Paragraph::new(Span::styled(prompt, Style::default().fg(Color::DarkGray))) + .alignment(Alignment::Right); f.render_widget( prompt_p, Rect { @@ -595,13 +818,10 @@ fn draw_select_list_with_info( /// 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; } +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)); @@ -611,7 +831,9 @@ fn draw_select_list_pip( .map(|(i, item)| { let style = if i == 0 { // Header row — match right pane header color - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else if i >= 2 { // Data rows — match right pane value color dim_val @@ -639,26 +861,36 @@ fn draw_select_list_pip( /// 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; } +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]) + 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 }, + 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; @@ -671,7 +903,12 @@ fn draw_right_pane( }; f.render_widget( Paragraph::new(msg), - Rect { x: area.x + 1, y, width: area.width.saturating_sub(2), height: 1 }, + Rect { + x: area.x + 1, + y, + width: area.width.saturating_sub(2), + height: 1, + }, ); return; } @@ -681,7 +918,12 @@ fn draw_right_pane( 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 }, + Rect { + x: area.x + 1, + y: y + i as u16, + width: area.width.saturating_sub(2), + height: 1, + }, ); } } @@ -752,7 +994,9 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &AppState) { /// it disappears for ~5.4s (30 cooldown ticks) before reappearing on the /// right. Two variants alternate every 400ms. pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) { - if area.width < 4 { return; } + 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; @@ -766,7 +1010,12 @@ pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) { 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 }, + Rect { + x: area.x + (x as u16).min(area.width.saturating_sub(4)), + y: area.y, + width: 4, + height: 1, + }, ); } } @@ -864,8 +1113,8 @@ pub fn draw_input_dialog( let prompt_w = state.prompt.len() as u16 + 4; let input_display = &state.input; let display_w = input_display.len() + 4; // rough, but good enough for sizing - let popup_w = (prompt_w.max(display_w as u16).max(40) + 4) - .min(f.area().width.saturating_sub(4)); + let popup_w = + (prompt_w.max(display_w as u16).max(40) + 4).min(f.area().width.saturating_sub(4)); let popup_h = 10u16.min(f.area().height.saturating_sub(4)); let area = centered_rect_size(popup_w, popup_h, f.area()); f.render_widget(Clear, area); @@ -882,7 +1131,9 @@ pub fn draw_input_dialog( f.render_widget( Paragraph::new(Span::styled( "Set Save Folder", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )), Rect { height: 1, ..inner }, ); @@ -893,14 +1144,17 @@ pub fn draw_input_dialog( &state.prompt, Style::default().fg(Color::White), )), - Rect { y: inner.y + 2, height: 1, width: inner.width, x: inner.x }, + Rect { + y: inner.y + 2, + height: 1, + width: inner.width, + x: inner.x, + }, ); // Input line with cursor let cursor_visible = (std::time::Instant::now().elapsed().as_millis() / 500).is_multiple_of(2); - let mut input_spans = vec![ - Span::styled(" ", Style::default()), - ]; + let mut input_spans = vec![Span::styled(" ", Style::default())]; // Show the text up to cursor let before = &state.input[..state.cursor.min(state.input.len())]; let after = if state.cursor < state.input.len() { @@ -908,25 +1162,21 @@ pub fn draw_input_dialog( } else { None }; - input_spans.push(Span::styled( - before, - Style::default().fg(Color::White), - )); + input_spans.push(Span::styled(before, Style::default().fg(Color::White))); if cursor_visible && !state.confirmed && !state.cancelled { - input_spans.push(Span::styled( - "█", - Style::default().fg(Color::Cyan), - )); + input_spans.push(Span::styled("█", Style::default().fg(Color::Cyan))); } if let Some(a) = after { - input_spans.push(Span::styled( - a, - Style::default().fg(Color::White), - )); + input_spans.push(Span::styled(a, Style::default().fg(Color::White))); } f.render_widget( Paragraph::new(Line::from(input_spans)), - Rect { y: inner.y + 3, height: 1, width: inner.width, x: inner.x }, + Rect { + y: inner.y + 3, + height: 1, + width: inner.width, + x: inner.x, + }, ); // Instruction line @@ -935,17 +1185,28 @@ pub fn draw_input_dialog( "Type a path, then Tab to buttons Enter to confirm Esc to cancel", Style::default().fg(Color::DarkGray), )), - Rect { y: inner.y + 5, height: 1, width: inner.width, x: inner.x }, + Rect { + y: inner.y + 5, + height: 1, + width: inner.width, + x: inner.x, + }, ); // OK / Cancel buttons let ok_style = if ok_selected { - Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Green) }; let cancel_style = if !ok_selected { - Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Red) }; @@ -956,11 +1217,21 @@ pub fn draw_input_dialog( ]); f.render_widget( Paragraph::new(buttons).alignment(Alignment::Center), - Rect { y: inner.y + 6, height: 1, width: inner.width, x: inner.x }, + Rect { + y: inner.y + 6, + height: 1, + width: inner.width, + x: inner.x, + }, ); // Whale - let bar = Rect { x: 0, y: f.area().height.saturating_sub(1), width: f.area().width, height: 1 }; + let bar = Rect { + x: 0, + y: f.area().height.saturating_sub(1), + width: f.area().width, + height: 1, + }; draw_whale_separator(f, bar, _app); }