diff options
Diffstat (limited to 'src/scenes/pause.rs')
| -rw-r--r-- | src/scenes/pause.rs | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/src/scenes/pause.rs b/src/scenes/pause.rs new file mode 100644 index 0000000..41cff43 --- /dev/null +++ b/src/scenes/pause.rs @@ -0,0 +1,160 @@ +//! The menu `esc` opens over a game screen: keep playing, do what `esc` +//! used to do there (abandon ship, back to your ship), or save and quit to +//! the title. It opens on keep playing, and `esc` again closes it: quitting +//! is always something you choose. +//! +//! Like the inventory, it's a panel a scene holds and draws over itself, +//! not a scene of its own: the scene beneath stays in view, and stops while +//! the menu's open. + +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ratatui::layout::{Constraint, Flex, Layout, Rect}; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; + +use super::keys::{self, ACCENT, DIM}; + +/// What you chose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + /// Save, and go back to the title screen. + Quit, + /// Whatever `esc` does here besides: abandon ship, leave the building. + Here, + /// Close the menu and carry on. + Resume, +} + +pub struct Pause { + choice: usize, + /// What else you can do here, if there's anything: "Abandon ship". + here: Option<&'static str>, + /// A word about where you are, or which way to go. + note: Option<String>, +} + +impl Pause { + pub fn new(here: Option<&'static str>, note: Option<String>) -> Pause { + Pause { choice: 0, here, note } + } + + fn choices(&self) -> Vec<(Choice, &'static str)> { + let mut choices = vec![(Choice::Resume, "Keep playing")]; + if let Some(here) = self.here { + choices.push((Choice::Here, here)); + } + choices.push((Choice::Quit, "Save and quit to the title")); + choices + } + + /// Takes a key. Says what you chose, if you chose anything. A key held + /// down doesn't count again: holding `esc` mustn't flicker the menu. + pub fn key(&mut self, key: KeyEvent) -> Option<Choice> { + if key.kind == KeyEventKind::Repeat { + return None; + } + let choices = self.choices(); + let last = choices.len() - 1; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => Some(Choice::Resume), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + self.choice = (self.choice + 1).min(last); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + self.choice = self.choice.saturating_sub(1); + None + } + KeyCode::Char('g') | KeyCode::Home => { + self.choice = 0; + None + } + KeyCode::Char('G') | KeyCode::End => { + self.choice = last; + None + } + KeyCode::Enter | KeyCode::Char(' ' | 'l') => Some(choices[self.choice.min(last)].0), + KeyCode::Char(c @ '1'..='9') => { + choices.get(c as usize - '1' as usize).map(|&(choice, _)| choice) + } + _ => None, + } + } + + /// Draws the menu in the middle of `area`. + pub fn render(&self, frame: &mut Frame, area: Rect) { + let mut lines = Vec::new(); + if let Some(note) = &self.note { + lines.push(Line::from(note.clone()).fg(DIM)); + lines.push(Line::default()); + } + for (i, (_, what)) in self.choices().into_iter().enumerate() { + let chosen = i == self.choice; + let marker = if chosen { "› " } else { " " }; + let line = Line::from(vec![ + Span::raw(format!("{} ", i + 1)).fg(ACCENT), + Span::raw(marker).fg(ACCENT), + Span::raw(what), + ]); + lines.push(if chosen { + line.patch_style(Style::new().add_modifier(Modifier::REVERSED)) + } else { + line + }); + } + lines.push(Line::default()); + lines.push(keys::line(&[("↑↓ enter", "choose"), ("esc", "keep playing")])); + + let [popup] = Layout::horizontal([Constraint::Max(48)]).flex(Flex::Center).areas(area); + let width = usize::from(popup.width.saturating_sub(4)).max(1); + let rows: usize = lines.iter().map(|l| l.width().div_ceil(width).max(1)).sum(); + let height = u16::try_from(rows + 2).unwrap_or(u16::MAX); + let [popup] = + Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(popup); + let block = Block::bordered() + .border_style(Style::new().fg(ACCENT)) + .title(Line::from(" PAUSED ").fg(ACCENT).bold()) + .padding(Padding::horizontal(1)); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }).block(block), popup); + } +} + +#[cfg(test)] +mod tests { + use ratatui::crossterm::event::KeyModifiers; + + use super::*; + + fn press(pause: &mut Pause, code: KeyCode) -> Option<Choice> { + pause.key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + #[test] + fn esc_again_closes_the_menu_and_holding_it_does_nothing() { + let mut pause = Pause::new(Some("Abandon ship"), None); + let held = KeyEvent::new_with_kind(KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Repeat); + assert_eq!(pause.key(held), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Esc), Some(Choice::Resume), "wherever the cursor is"); + } + + #[test] + fn you_can_pick_what_else_esc_does_or_carry_on() { + let mut pause = Pause::new(Some("Abandon ship"), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Resume), "it opens on that"); + assert_eq!(press(&mut pause, KeyCode::Char('j')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Here)); + assert_eq!(press(&mut pause, KeyCode::Char('G')), None); + assert_eq!(press(&mut pause, KeyCode::Enter), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('1')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('3')), Some(Choice::Quit)); + assert_eq!(press(&mut pause, KeyCode::Char('q')), Some(Choice::Resume)); + assert_eq!(press(&mut pause, KeyCode::Char('x')), None, "anything else does nothing"); + let mut pause = Pause::new(None, None); + assert_eq!(press(&mut pause, KeyCode::Char('2')), Some(Choice::Quit)); + } +} |