mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-23 02:52:37 +02:00
add manual path entry, deprecate auto-discovery
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
//! Save-folder discovery.
|
||||
//!
|
||||
//! **Deprecated.** Auto-scanning user profiles and system directories for
|
||||
//! Subnautica 2 saves is a privacy concern. Scheduled for removal in v0.3.0.
|
||||
//! Use `Set save folder` from the main menu instead to enter paths manually.
|
||||
//!
|
||||
//! Traverses known path patterns across user profiles and common install
|
||||
//! locations to find Subnautica 2 save folders.
|
||||
|
||||
|
||||
+137
-70
@@ -20,7 +20,7 @@ use anyhow::Result;
|
||||
use chrono::TimeZone;
|
||||
use config::AppConfig;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
event::{self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
@@ -49,7 +49,7 @@ fn main() -> Result<()> {
|
||||
// ── setup terminal ─────────────────────────────────────────────────
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
@@ -60,6 +60,7 @@ fn main() -> Result<()> {
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
@@ -75,6 +76,7 @@ struct App {
|
||||
log_path: PathBuf,
|
||||
save_folder: Option<PathBuf>,
|
||||
tui_state: tui::AppState,
|
||||
discovery_deprecation_shown: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -98,6 +100,7 @@ impl App {
|
||||
log_path,
|
||||
save_folder,
|
||||
tui_state,
|
||||
discovery_deprecation_shown: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,30 +170,8 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scan on startup if no cached path
|
||||
let save_found = if app.save_folder.is_some() {
|
||||
true
|
||||
} else {
|
||||
app.set_spinner(true);
|
||||
terminal.draw(|f| {
|
||||
tui::draw_text_screen(f, &app.tui_state,
|
||||
&[Line::from(Span::styled("Locating save folder…", Style::default().add_modifier(Modifier::BOLD)))],
|
||||
"",
|
||||
);
|
||||
})?;
|
||||
let folders = discovery::discover_save_folders();
|
||||
app.set_spinner(false);
|
||||
if let Some(first) = folders.first() {
|
||||
app.save_folder = Some(first.path.clone());
|
||||
app.config.save_path = Some(first.path.display().to_string());
|
||||
app.config.save_scan = Some(chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
crate::config::save_config(&app.ini_path, &app.config)?;
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
// Load cached path only — no auto-scan (privacy: discovery scans user profiles)
|
||||
let save_found = app.save_folder.is_some();
|
||||
|
||||
// Main menu loop
|
||||
let mut menu_state = ListState::default().with_selected(Some(0));
|
||||
@@ -206,7 +187,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
||||
tui::draw_main_menu(f, &mut menu_state, &app.tui_state, save_found);
|
||||
})?;
|
||||
|
||||
let max_idx = if save_found { 6 } else { 7 };
|
||||
let max_idx = 8usize;
|
||||
if event::poll(std::time::Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Release { continue; }
|
||||
@@ -221,21 +202,20 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>) -> Result<()> {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let idx = menu_state.selected().unwrap_or(0);
|
||||
// Remap: when locate is hidden, shift by 1
|
||||
let remap = if save_found { idx + 1 } else { idx };
|
||||
match remap {
|
||||
0 => action_locate_saves(terminal, &mut app)?,
|
||||
1 => action_recover_bak(terminal, &mut app)?,
|
||||
2 => action_create_backup(terminal, &mut app)?,
|
||||
3 => action_restore_backup(terminal, &mut app)?,
|
||||
4 => action_inspect_saves(terminal, &mut app)?,
|
||||
5 => run_ini_submenu(terminal, &mut app)?,
|
||||
6 => {
|
||||
match idx {
|
||||
0 => action_set_save_folder(terminal, &mut app)?,
|
||||
1 => action_locate_saves(terminal, &mut app)?,
|
||||
2 => action_recover_bak(terminal, &mut app)?,
|
||||
3 => action_create_backup(terminal, &mut app)?,
|
||||
4 => action_restore_backup(terminal, &mut app)?,
|
||||
5 => action_inspect_saves(terminal, &mut app)?,
|
||||
6 => run_ini_submenu(terminal, &mut app)?,
|
||||
7 => {
|
||||
if let Some(false) = run_disclaimer(terminal, &mut app)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
7 => return Ok(()), // Exit
|
||||
8 => return Ok(()), // Exit
|
||||
_ => {}
|
||||
}
|
||||
app.clear_status();
|
||||
@@ -300,8 +280,119 @@ fn run_disclaimer<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resu
|
||||
///
|
||||
/// Spawns a background thread for the scan and shows a live elapsed timer.
|
||||
// ── menu actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Open the input dialog for the user to type a save-folder path.
|
||||
/// Validates the path exists and contains .sav files before accepting it.
|
||||
fn action_set_save_folder<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
let mut input_state = tui::InputDialogState::new(
|
||||
"Enter the path to your Subnautica 2 SaveGames folder:",
|
||||
);
|
||||
let mut ok_selected = true;
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| {
|
||||
tui::draw_input_dialog(f, &app.tui_state, &input_state, ok_selected);
|
||||
})?;
|
||||
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(250))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Release { continue; }
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if ok_selected && !input_state.input.is_empty() {
|
||||
let candidate = discovery::validate_custom_path(&input_state.input);
|
||||
if let Some(path) = candidate {
|
||||
app.save_folder = Some(path.clone());
|
||||
app.config.save_path = Some(path.display().to_string());
|
||||
crate::config::save_config(&app.ini_path, &app.config)?;
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
let msg = format!("Save folder set to {}", path.display());
|
||||
app.set_status(&msg, tui::StatusStyle::Success);
|
||||
input_state.confirmed = true;
|
||||
return Ok(());
|
||||
} else {
|
||||
// Invalid path — show error and let them retry
|
||||
ok_dialog(terminal, app, "Invalid Path",
|
||||
"The path you entered does not exist or\n\
|
||||
does not contain any .sav save files.\n\
|
||||
\n\
|
||||
Please enter the full path to your\n\
|
||||
SaveGames folder (e.g.\n\
|
||||
/home/user/.../SaveGames)."
|
||||
)?;
|
||||
input_state.reset();
|
||||
ok_selected = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Cancel was selected
|
||||
input_state.cancelled = true;
|
||||
return Ok(());
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if ok_selected {
|
||||
input_state.insert(c);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if ok_selected {
|
||||
input_state.backspace();
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if ok_selected {
|
||||
input_state.delete();
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if ok_selected {
|
||||
input_state.cursor_left();
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if ok_selected {
|
||||
input_state.cursor_right();
|
||||
}
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
ok_selected = !ok_selected;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
input_state.cancelled = true;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Paste(s) => {
|
||||
if ok_selected {
|
||||
input_state.insert_str(&s);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Caches the first match as `save_path` in config.ini.
|
||||
fn action_locate_saves<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||
// Deprecation notice — show once per session
|
||||
if !app.discovery_deprecation_shown {
|
||||
app.discovery_deprecation_shown = true;
|
||||
ok_dialog(terminal, app,
|
||||
"Deprecation Notice",
|
||||
"Auto-scan for save folders is deprecated and will\n\
|
||||
be removed in a future release.\n\
|
||||
\n\
|
||||
Please use 'Set save folder' from the main menu\n\
|
||||
to enter your save path manually. This protects\n\
|
||||
your privacy by not scanning other user profiles\n\
|
||||
and system directories.",
|
||||
)?;
|
||||
// Let the user decide — proceed with the scan anyway
|
||||
}
|
||||
// Run scan on a background thread so we can show a live elapsed timer.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let scan_start = std::time::Instant::now();
|
||||
@@ -1020,29 +1111,19 @@ fn poll_key(timeout_ms: u64) -> Result<Option<crossterm::event::KeyEvent>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Ensure we have a save folder — prompt discovery if not.
|
||||
/// Ensure we have a cached save folder path that exists.
|
||||
fn ensure_save_folder<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) -> Result<PathBuf> {
|
||||
// Try cached path first
|
||||
if let Some(ref sf) = app.save_folder {
|
||||
if sf.exists() {
|
||||
return Ok(sf.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Discover
|
||||
let folders = discovery::discover_save_folders();
|
||||
if let Some(first) = folders.first() {
|
||||
app.save_folder = Some(first.path.clone());
|
||||
app.config.save_path = Some(first.path.display().to_string());
|
||||
crate::config::save_config(&app.ini_path, &app.config)?;
|
||||
refresh_stats(&mut app.tui_state, app.save_folder.as_deref());
|
||||
return Ok(first.path.clone());
|
||||
}
|
||||
|
||||
anyhow::bail!("No save folders found. Run 'Locate Subnautica save files' from the main menu.")
|
||||
anyhow::bail!(
|
||||
"No save folder set. Use 'Set save folder' from the main menu first."
|
||||
)
|
||||
}
|
||||
|
||||
/// Get or discover the Config\Windows path.
|
||||
/// Derive the Config\Windows path from the save folder or cached config.
|
||||
fn get_ini_path<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) -> Result<PathBuf> {
|
||||
// Try cached config path
|
||||
if let Some(ref cp) = app.config.ini_path {
|
||||
@@ -1059,23 +1140,9 @@ fn get_ini_path<B: Backend>(_terminal: &mut Terminal<B>, app: &mut App) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to discovery
|
||||
let folders = discovery::discover_save_folders();
|
||||
for f in &folders {
|
||||
if let Some(cp) = discovery::derive_ini_path(&f.path) {
|
||||
app.config.ini_path = Some(cp.display().to_string());
|
||||
crate::config::save_config(&app.ini_path, &app.config)?;
|
||||
return Ok(cp);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("Cannot determine Config/Windows path. Run 'Locate Subnautica save files' first.")
|
||||
}
|
||||
|
||||
/// Determine if a save folder is cloud-backed (Xbox/Game Pass wgs path).
|
||||
fn is_cloud_path(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
s.contains("\\packages\\") || s.contains("/packages/") || s.contains("\\wgs") || s.contains("/wgs")
|
||||
anyhow::bail!(
|
||||
"Cannot determine Config/Windows path. Set your save folder first via 'Set save folder'."
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the numeric suffix from a slot name like "savegame_3".
|
||||
|
||||
+213
-46
@@ -75,52 +75,30 @@ impl Default for AppState {
|
||||
|
||||
// ── public rendering entry points ──────────────────────────────────────────
|
||||
|
||||
/// Draw the main menu. Hides the locate item when saves are already found.
|
||||
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState, save_found: bool) {
|
||||
let items: Vec<&str> = if save_found {
|
||||
vec![
|
||||
" Recover save file",
|
||||
" Create full backup",
|
||||
" Restore full backup",
|
||||
" Inspect save files",
|
||||
" Manage UE5 Config (.ini) files",
|
||||
" View disclaimer",
|
||||
" Exit",
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
" Locate Subnautica save files",
|
||||
" Recover save file",
|
||||
" Create full backup",
|
||||
" Restore full backup",
|
||||
" Inspect save files",
|
||||
" Manage UE5 Config (.ini) files",
|
||||
" View disclaimer",
|
||||
" Exit",
|
||||
]
|
||||
};
|
||||
let descs: Vec<&str> = if save_found {
|
||||
vec![
|
||||
"Restore a save file from a backup",
|
||||
"Copy the savegame files to NotAlterra_Backups",
|
||||
"Restore a full backup from NotAlterra_Backups",
|
||||
"View detailed GVAS metadata for each save file",
|
||||
"Backup, restore, or delete .ini files in Config/Windows",
|
||||
"Re-read the disclaimer and terms of use",
|
||||
"Close NotAlterra",
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"Scan all drives for Subnautica 2 save folders",
|
||||
"Restore a save file from a backup",
|
||||
"Copy the savegame files to NotAlterra_Backups",
|
||||
"Restore a full backup from NotAlterra_Backups",
|
||||
"View detailed GVAS metadata for each save file",
|
||||
"Backup, restore, or delete .ini files in Config/Windows",
|
||||
"Re-read the disclaimer and terms of use",
|
||||
"Close NotAlterra",
|
||||
]
|
||||
};
|
||||
/// Draw the main menu.
|
||||
pub fn draw_main_menu(f: &mut Frame, state: &mut ListState, app: &AppState, _save_found: bool) {
|
||||
let items: Vec<&str> = vec![
|
||||
" Set save folder",
|
||||
" Locate save files (deprecated)",
|
||||
" Recover save file",
|
||||
" Create full backup",
|
||||
" Restore full backup",
|
||||
" Inspect save files",
|
||||
" Manage UE5 Config (.ini) files",
|
||||
" View disclaimer",
|
||||
" Exit",
|
||||
];
|
||||
let descs: Vec<&str> = vec![
|
||||
"Enter your save folder path manually",
|
||||
"Auto-scan for save folders (deprecated — will be removed in a future release)",
|
||||
"Restore a save file from a backup",
|
||||
"Copy the savegame files to NotAlterra_Backups",
|
||||
"Restore a full backup from NotAlterra_Backups",
|
||||
"View detailed GVAS metadata for each save file",
|
||||
"Backup, restore, or delete .ini files in Config/Windows",
|
||||
"Re-read the disclaimer and terms of use",
|
||||
"Close NotAlterra",
|
||||
];
|
||||
let chunks = standard_layout(f.area(), items.len());
|
||||
|
||||
draw_header(f, chunks[0], app);
|
||||
@@ -632,6 +610,195 @@ pub fn draw_whale_separator(f: &mut Frame, area: Rect, app: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── input dialog ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// State for the text-input dialog used to enter a save-folder path.
|
||||
pub struct InputDialogState {
|
||||
pub input: String,
|
||||
pub cursor: usize,
|
||||
pub prompt: String,
|
||||
pub confirmed: bool,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl InputDialogState {
|
||||
pub fn new(prompt: impl Into<String>) -> Self {
|
||||
Self {
|
||||
input: String::new(),
|
||||
cursor: 0,
|
||||
prompt: prompt.into(),
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor = 0;
|
||||
self.confirmed = false;
|
||||
self.cancelled = false;
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.input.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Insert a string at the cursor position (for paste).
|
||||
pub fn insert_str(&mut self, s: &str) {
|
||||
self.input.insert_str(self.cursor, s);
|
||||
self.cursor += s.len();
|
||||
}
|
||||
|
||||
/// Delete the character before the cursor (Backspace).
|
||||
pub fn backspace(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
let prev = self.cursor.saturating_sub(1);
|
||||
self.input.remove(prev);
|
||||
self.cursor = prev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor (Delete).
|
||||
pub fn delete(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.input.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor left by one grapheme boundary.
|
||||
pub fn cursor_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor = self.input[..self.cursor]
|
||||
.char_indices()
|
||||
.nth_back(0)
|
||||
.map(|(i, _c)| i)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move cursor right by one grapheme boundary.
|
||||
pub fn cursor_right(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.cursor = self.input[self.cursor..]
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map(|(i, _c)| self.cursor + i)
|
||||
.unwrap_or(self.input.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the text input dialog for entering a custom save-folder path.
|
||||
///
|
||||
/// Layout: title bar, prompt, input line with cursor indicator,
|
||||
/// instruction line, and [ OK ] [ Cancel ] buttons.
|
||||
pub fn draw_input_dialog(
|
||||
f: &mut Frame,
|
||||
_app: &AppState,
|
||||
state: &InputDialogState,
|
||||
ok_selected: bool,
|
||||
) {
|
||||
let prompt_w = state.prompt.len() as u16 + 4;
|
||||
let input_display = &state.input;
|
||||
let display_w = input_display.len() + 4; // rough, but good enough for sizing
|
||||
let popup_w = (prompt_w.max(display_w as u16).max(40) + 4)
|
||||
.min(f.area().width.saturating_sub(4));
|
||||
let popup_h = 10u16.min(f.area().height.saturating_sub(4));
|
||||
let area = centered_rect_size(popup_w, popup_h, f.area());
|
||||
f.render_widget(Clear, area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(block, area);
|
||||
|
||||
let inner = inner(area, 2, 1);
|
||||
|
||||
// Title
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"Set Save Folder",
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Rect { height: 1, ..inner },
|
||||
);
|
||||
|
||||
// Prompt text
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
&state.prompt,
|
||||
Style::default().fg(Color::White),
|
||||
)),
|
||||
Rect { y: inner.y + 2, height: 1, width: inner.width, x: inner.x },
|
||||
);
|
||||
|
||||
// Input line with cursor
|
||||
let cursor_visible = (std::time::Instant::now().elapsed().as_millis() / 500) % 2 == 0;
|
||||
let mut input_spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
];
|
||||
// Show the text up to cursor
|
||||
let before = &state.input[..state.cursor.min(state.input.len())];
|
||||
let after = if state.cursor < state.input.len() {
|
||||
Some(&state.input[state.cursor..])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
input_spans.push(Span::styled(
|
||||
before,
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
if cursor_visible && !state.confirmed && !state.cancelled {
|
||||
input_spans.push(Span::styled(
|
||||
"█",
|
||||
Style::default().fg(Color::Cyan),
|
||||
));
|
||||
}
|
||||
if let Some(a) = after {
|
||||
input_spans.push(Span::styled(
|
||||
a,
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
}
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(input_spans)),
|
||||
Rect { y: inner.y + 3, height: 1, width: inner.width, x: inner.x },
|
||||
);
|
||||
|
||||
// Instruction line
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"Type a path, then Tab to buttons Enter to confirm Esc to cancel",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Rect { y: inner.y + 5, height: 1, width: inner.width, x: inner.x },
|
||||
);
|
||||
|
||||
// OK / Cancel buttons
|
||||
let ok_style = if ok_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
};
|
||||
let cancel_style = if !ok_selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Red).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
let buttons = Line::from(vec![
|
||||
Span::styled(" [ OK ] ", ok_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(" [ Cancel ] ", cancel_style),
|
||||
]);
|
||||
f.render_widget(
|
||||
Paragraph::new(buttons).alignment(Alignment::Center),
|
||||
Rect { y: inner.y + 6, height: 1, width: inner.width, x: inner.x },
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncate a path to show the tail (most specific directories).
|
||||
/// e.g. `C:\Users\...\Subnautica2\Saved\SaveGames` → `…\Subnautica2\Saved\SaveGames`
|
||||
fn truncate_path_tail(path: &str, max_width: usize) -> String {
|
||||
|
||||
Reference in New Issue
Block a user