fix clippy warnings and cargo fmt issues

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