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
+18 -10
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,16 +40,17 @@ 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
notalterra::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into()) .as_ref()
}); .and_then(|m| m.slot_name.clone())
.unwrap_or_else(|| {
notalterra::gvas::derive_slot_from_filename(&name).unwrap_or_else(|| "?".into())
});
let display = meta let display = meta
.as_ref() .as_ref()
.and_then(|m| m.display_name.clone()) .and_then(|m| m.display_name.clone())
@@ -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("."))
} }
+52 -32
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,13 +100,16 @@ 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()
found.push(DiscoveredFolder { && has_save_files(&candidate)
label: label.to_string(), && seen.insert(candidate.clone())
path: candidate, {
}); found.push(DiscoveredFolder {
} label: label.to_string(),
path: candidate,
});
}
} }
} }
@@ -140,11 +152,9 @@ fn has_save_files(dir: &Path) -> bool {
let check = |ext: &str| -> bool { 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,15 +325,17 @@ 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(), });
}); }
}
if path.is_dir() { if path.is_dir() {
queue.push_back(path); queue.push_back(path);
+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);
+505 -218
View File
File diff suppressed because it is too large Load Diff
+128 -46
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,19 +338,28 @@ 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()) let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
.and_then(|t| { Local
let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); .timestamp_opt(secs as i64, 0)
Local.timestamp_opt(secs as i64, 0).single() .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());
let meta = extract_metadata(&path).ok(); let meta = extract_metadata(&path).ok();
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]
+413 -142
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(
.alignment(Alignment::Center), title,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
.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()
Line::from(vec![ .map(|(k, v)| {
Span::styled(format!("{k}: "), Style::default().fg(icon)), let icon = if k.starts_with('⚠') {
Span::styled(*v, Style::default()), Color::Yellow
]) } else {
}).collect(); Color::Gray
};
Line::from(vec![
Span::styled(format!("{k}: "), Style::default().fg(icon)),
Span::styled(*v, Style::default()),
])
})
.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,29 +501,28 @@ 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, .alignment(Alignment::Center);
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Center);
f.render_widget(prompt_p, chunks[3]); f.render_widget(prompt_p, chunks[3]);
} }
@@ -354,12 +561,13 @@ fn standard_layout(area: Rect, _menu_items: usize) -> Vec<Rect> {
Layout::default() Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // header Constraint::Length(3), // header
Constraint::Length(2), // dashboard Constraint::Length(2), // dashboard
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,11 +711,8 @@ 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, .alignment(Alignment::Right);
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
f.render_widget( f.render_widget(
prompt_p, prompt_p,
Rect { Rect {
@@ -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,11 +800,8 @@ 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, .alignment(Alignment::Right);
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
f.render_widget( f.render_widget(
prompt_p, prompt_p,
Rect { Rect {
@@ -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);
} }