//! The saved characters: pick one up where you left them, or delete them. use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Constraint, Flex, Layout}; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Padding, Paragraph}; use super::flight; use super::forge::colour; use super::keys::{self, ACCENT, DIM}; use super::{Scene, Transition}; use crate::app::Ctx; use crate::save::{self, Slot}; /// Lines each save takes in the list, the gap after it included. const ROWS_EACH: u16 = 3; pub struct Saves { slots: Vec, selected: usize, /// Asking whether to delete the selected save. deleting: bool, problem: Option, } impl Saves { pub fn new(ctx: &Ctx) -> Saves { let slots = ctx.saves.as_deref().map(save::list).unwrap_or_default(); // Whoever you're playing is first, being the most recently saved. Saves { slots, selected: 0, deleting: false, problem: None } } fn playing(&self, ctx: &Ctx, i: usize) -> bool { ctx.slot.as_ref() == Some(&self.slots[i].path) } fn load(&mut self, ctx: &mut Ctx) -> Transition { let Some(slot) = self.slots.get(self.selected) else { return Transition::None }; if let Err(why) = &slot.summary { self.problem = Some(format!("That save can't be read: {why}")); return Transition::None; } if !self.playing(ctx, self.selected) { ctx.autosave(); if let Err(why) = save::load(&slot.path, ctx) { self.problem = Some(format!("That save can't be read: {why}")); return Transition::None; } } Transition::Replace(flight::resume(ctx)) } fn delete(&mut self, ctx: &mut Ctx) { let path = self.slots[self.selected].path.clone(); if let Err(why) = save::delete(&path) { self.problem = Some(format!("Couldn't delete it: {why}")); return; } // Delete who you're playing and there's no one left to save. if ctx.slot.as_ref() == Some(&path) { ctx.slot = None; } self.slots.remove(self.selected); self.selected = self.selected.min(self.slots.len().saturating_sub(1)); } } /// "just now", "5 minutes ago", "3 days ago". fn ago(seconds: u64) -> String { let (n, unit) = match seconds { 0..60 => return "just now".into(), 60..3600 => (seconds / 60, "minute"), 3600..86_400 => (seconds / 3600, "hour"), _ => (seconds / 86_400, "day"), }; match n { 1 if unit == "hour" => "an hour ago".into(), 1 => format!("a {unit} ago"), _ => format!("{n} {unit}s ago"), } } impl Scene for Saves { fn key(&mut self, key: KeyEvent, ctx: &mut Ctx) -> Transition { self.problem = None; if self.deleting { self.deleting = false; if matches!(key.code, KeyCode::Char('y' | 'Y')) { self.delete(ctx); } return Transition::None; } let last = self.slots.len().saturating_sub(1); match key.code { KeyCode::Esc | KeyCode::Char('q') => return Transition::Pop, KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1), KeyCode::Down | KeyCode::Char('j') => self.selected = (self.selected + 1).min(last), KeyCode::Enter => return self.load(ctx), KeyCode::Char('d') | KeyCode::Delete if !self.slots.is_empty() => self.deleting = true, _ => {} } Transition::None } fn render(&self, frame: &mut Frame, ctx: &Ctx) { let [area] = Layout::horizontal([Constraint::Length(84)]).flex(Flex::Center).areas(frame.area()); let block = Block::bordered() .border_style(Style::new().fg(DIM)) .title(Line::from(" SAVED CHARACTERS ").fg(ACCENT).bold()) .padding(Padding::uniform(1)); let inner = block.inner(area); frame.render_widget(block, area); let [list, _, footer] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1), Constraint::Length(2)]) .areas(inner); let mut lines = Vec::new(); if self.slots.is_empty() { lines.push( Line::from("No one's saved yet. Make a new character from the title.").fg(DIM), ); } // Enough of the list to keep the selected save in view. let shown = usize::from((list.height / ROWS_EACH).max(1)); let first = (self.selected + 1).saturating_sub(shown); let now = save::now(); for (i, slot) in self.slots.iter().enumerate().skip(first).take(shown) { let chosen = i == self.selected; let marker = if chosen { Span::raw("› ").fg(ACCENT).bold() } else { Span::raw(" ") }; match &slot.summary { Ok(s) => { let c = &s.character; let mut who = vec![ marker, Span::raw(c.name.clone()).fg(colour(c.origin)).bold(), Span::raw(format!(", {}, of {}", c.calling(), c.ship_name())), ]; if self.playing(ctx, i) { who.push(Span::raw(" playing").fg(ACCENT)); } lines.push(Line::from(who)); let boarded = match s.record.boarded { 1 => "1 wreck boarded".to_string(), n => format!("{n} wrecks boarded"), }; lines.push( Line::from(format!( " day {:.1} at {} · {} ¤ · {boarded} · saved {}", s.day, s.place, s.scrip, ago(now.saturating_sub(s.saved)) )) .fg(DIM), ); } Err(why) => { let file = slot.path.file_name().unwrap_or_default().to_string_lossy(); lines.push(Line::from(vec![marker, Span::raw(file.into_owned()).fg(DIM)])); lines.push(Line::from(format!(" can't be read: {why}")).fg(Color::LightRed)); } } lines.push(Line::default()); } frame.render_widget(Paragraph::new(lines), list); let name = |i: usize| match &self.slots[i].summary { Ok(s) => s.character.name.clone(), Err(_) => "this save".into(), }; let mut help = Vec::new(); if self.deleting { let who = name(self.selected); help.push(Line::from(format!("Delete {who} for good?")).fg(Color::LightRed)); help.push(keys::line(&[("y", "delete"), ("any other key", "keep them")])); } else { if let Some(problem) = &self.problem { help.push(Line::from(problem.as_str()).fg(Color::LightRed)); } else if let Some(dir) = &ctx.saves { help.push(Line::from(format!("saved in {}", dir.display())).fg(DIM)); } help.push(keys::line(&[ ("↑↓", "choose"), ("enter", "play"), ("d", "delete"), ("esc", "back"), ])); } frame.render_widget(Paragraph::new(help), footer); } } #[cfg(test)] mod tests { use ratatui::crossterm::event::KeyModifiers; use super::*; use crate::names::Culture; use crate::save::Character; use crate::term::KeyboardCaps; fn press(scene: &mut Saves, ctx: &mut Ctx, code: KeyCode) -> Transition { scene.key(KeyEvent::new(code, KeyModifiers::NONE), ctx) } /// A saves folder with two characters in it, and the game playing the /// second. fn two(dir: &std::path::Path) -> Ctx { let mut ctx = Ctx::new(1, KeyboardCaps { key_release: false }); ctx.saves = Some(dir.to_path_buf()); for (name, universe, scrip) in [("Ada Vell", 11, 300), ("Bram Oskel", 22, 5)] { let character = Character { name: name.into(), origin: Culture::Cant, ship: "Bolt".into() }; ctx.begin(universe, character); ctx.slot = Some(save::new_slot(dir, &ctx.character, universe)); ctx.hold.scrip = scrip; ctx.autosave(); assert!(ctx.save_error.is_none()); } ctx } #[test] fn loading_picks_up_where_they_left_off() { let dir = save::scratch("saves-load"); let mut ctx = two(&dir); let mut saves = Saves::new(&ctx); assert_eq!(saves.slots.len(), 2); // Both were saved within the same second, so either may come first. let ada = saves .slots .iter() .position(|s| s.summary.as_ref().unwrap().character.name == "Ada Vell") .unwrap(); ctx.hold.scrip = 6; saves.selected = ada; assert!(matches!(press(&mut saves, &mut ctx, KeyCode::Enter), Transition::Replace(_))); assert_eq!((ctx.character.name.as_str(), ctx.seed, ctx.hold.scrip), ("Ada Vell", 11, 300)); // Bram was saved as they were left, scrip and all. let bram = save::new_slot( &dir, &Character { name: "Bram Oskel".into(), origin: Culture::Cant, ship: "Bolt".into() }, 22, ); save::load(&bram, &mut ctx).unwrap(); assert_eq!(ctx.hold.scrip, 6); let _ = std::fs::remove_dir_all(dir); } #[test] fn deleting_asks_first() { let dir = save::scratch("saves-delete"); let mut ctx = two(&dir); let mut saves = Saves::new(&ctx); press(&mut saves, &mut ctx, KeyCode::Char('d')); press(&mut saves, &mut ctx, KeyCode::Char('n')); assert_eq!(saves.slots.len(), 2, "anything but yes keeps it"); let playing = saves.playing(&ctx, 0); press(&mut saves, &mut ctx, KeyCode::Char('d')); press(&mut saves, &mut ctx, KeyCode::Char('y')); assert_eq!((saves.slots.len(), save::list(&dir).len()), (1, 1)); assert_eq!(ctx.slot.is_none(), playing, "delete who you're playing and nothing's saved"); let _ = std::fs::remove_dir_all(dir); } #[test] fn saved_times_read_naturally() { assert_eq!(ago(5), "just now"); assert_eq!(ago(60), "a minute ago"); assert_eq!(ago(3 * 3600 + 5), "3 hours ago"); assert_eq!(ago(3600), "an hour ago"); assert_eq!(ago(86_400 * 2), "2 days ago"); } }