//! Keys every screen shares: which way the movement keys go, and how key //! hints are drawn. A hint is the key in the accent colour, bold, then what //! it does, dimmed; hints sit two spaces apart, and side panels pack as //! many to a line as fit. use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::style::{Color, Stylize}; use ratatui::text::{Line, Span}; pub const ACCENT: Color = Color::Rgb(240, 200, 110); pub const DIM: Color = Color::Rgb(120, 124, 142); /// Vi keys, arrows, and the number pad with or without num lock. pub fn direction(code: KeyCode) -> Option<(i32, i32)> { Some(match code { KeyCode::Left | KeyCode::Char('h' | '4') => (-1, 0), KeyCode::Right | KeyCode::Char('l' | '6') => (1, 0), KeyCode::Up | KeyCode::Char('k' | '8') => (0, -1), KeyCode::Down | KeyCode::Char('j' | '2') => (0, 1), KeyCode::Home | KeyCode::Char('y' | '7') => (-1, -1), KeyCode::PageUp | KeyCode::Char('u' | '9') => (1, -1), KeyCode::End | KeyCode::Char('b' | '1') => (-1, 1), KeyCode::PageDown | KeyCode::Char('n' | '3') => (1, 1), _ => return None, }) } /// A step, or with shift a run: which way, and whether to run. Shifted vi /// keys come as capitals. pub fn movement(key: KeyEvent) -> Option<(i32, i32, bool)> { let (code, run) = match key.code { KeyCode::Char(c) if c.is_ascii_uppercase() => (KeyCode::Char(c.to_ascii_lowercase()), true), code => (code, key.modifiers.contains(KeyModifiers::SHIFT)), }; direction(code).map(|(dx, dy)| (dx, dy, run)) } /// One hint: "esc back". fn hint(key: &str, what: &str) -> [Span<'static>; 2] { [Span::raw(key.to_string()).fg(ACCENT).bold(), Span::raw(format!(" {what}")).fg(DIM)] } /// Hints in a row. pub fn line(hints: &[(&str, &str)]) -> Line<'static> { let mut spans = Vec::new(); for (i, &(key, what)) in hints.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); } spans.extend(hint(key, what)); } Line::from(spans) } /// Hints packed into lines at most `width` wide, as many to a line as fit. /// A hint is never split; one too wide for any line gets a line to itself. pub fn packed(hints: &[(&str, &str)], width: u16) -> Vec> { let width = usize::from(width); let mut lines: Vec> = Vec::new(); for &(key, what) in hints { let [k, w] = hint(key, what); let needs = k.width() + w.width(); match lines.last_mut() { Some(last) if last.width() + 2 + needs <= width => { last.spans.extend([Span::raw(" "), k, w]); } _ => lines.push(Line::from(vec![k, w])), } } lines } #[cfg(test)] mod tests { use super::*; fn text(line: &Line) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect() } #[test] fn hints_pack_without_splitting() { let hints = [("f", "fire"), ("r", "reload"), ("esc", "menu: save and quit")]; let lines: Vec = packed(&hints, 16).iter().map(text).collect(); assert_eq!(lines, ["f fire r reload", "esc menu: save and quit"]); assert_eq!(text(&line(&hints[..2])), "f fire r reload"); } #[test] fn shift_or_a_capital_runs() { let key = |code, modifiers| movement(KeyEvent::new(code, modifiers)); assert_eq!(key(KeyCode::Char('h'), KeyModifiers::NONE), Some((-1, 0, false))); assert_eq!(key(KeyCode::Char('L'), KeyModifiers::SHIFT), Some((1, 0, true))); assert_eq!(key(KeyCode::Down, KeyModifiers::SHIFT), Some((0, 1, true))); assert_eq!(key(KeyCode::Char('G'), KeyModifiers::SHIFT), None); } }