| 1 | use ratatui::{ |
| 2 | Frame, |
| 3 | layout::{Constraint, Rect}, |
| 4 | text::Span, |
| 5 | widgets::{Block, Row}, |
| 6 | }; |
| 7 | |
| 8 | use super::{Popup, PopupState, frame_popup}; |
| 9 | use crate::{ |
| 10 | action::{Action, Context}, |
| 11 | config::KeyCombo, |
| 12 | screens::Ctx, |
| 13 | ui::list::ListView, |
| 14 | }; |
| 15 | |
| 16 | enum HelpRow { |
| 17 | Heading(String), |
| 18 | Binding { |
| 19 | keys: String, |
| 20 | description: &'static str, |
| 21 | }, |
| 22 | } |
| 23 | |
| 24 | /// Lists the key bindings, starting with the active screen's. |
| 25 | pub struct HelpPopup { |
| 26 | rows: Vec<HelpRow>, |
| 27 | list: ListView, |
| 28 | } |
| 29 | |
| 30 | impl HelpPopup { |
| 31 | pub fn new(screen: Context, ctx: &Ctx) -> Self { |
| 32 | let mut order = vec![screen, Context::Navigation, Context::Global]; |
| 33 | let rest: Vec<Context> = Context::ALL |
| 34 | .iter() |
| 35 | .copied() |
| 36 | .filter(|c| !order.contains(c) && !is_screen_context(*c)) |
| 37 | .collect(); |
| 38 | order.extend(rest); |
| 39 | let mut rows = Vec::new(); |
| 40 | for context in order { |
| 41 | let Some(actions) = ctx.config.keymap.bindings().get(&context) else { |
| 42 | continue; |
| 43 | }; |
| 44 | let bound: Vec<_> = actions |
| 45 | .iter() |
| 46 | .filter(|(_, keys)| !keys.is_empty()) |
| 47 | .collect(); |
| 48 | if bound.is_empty() { |
| 49 | continue; |
| 50 | } |
| 51 | if !rows.is_empty() { |
| 52 | rows.push(HelpRow::Heading(String::new())); |
| 53 | } |
| 54 | rows.push(HelpRow::Heading(heading(context))); |
| 55 | for (action, keys) in bound { |
| 56 | rows.push(HelpRow::Binding { |
| 57 | keys: keys |
| 58 | .iter() |
| 59 | .map(ToString::to_string) |
| 60 | .collect::<Vec<_>>() |
| 61 | .join(", "), |
| 62 | description: action.description(), |
| 63 | }); |
| 64 | } |
| 65 | } |
| 66 | Self { |
| 67 | rows, |
| 68 | list: ListView::default(), |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn is_screen_context(context: Context) -> bool { |
| 74 | !matches!( |
| 75 | context, |
| 76 | Context::Global | Context::Navigation | Context::Popup | Context::Input |
| 77 | ) |
| 78 | } |
| 79 | |
| 80 | fn heading(context: Context) -> String { |
| 81 | let name = context.name().replace('_', " "); |
| 82 | let mut chars = name.chars(); |
| 83 | chars |
| 84 | .next() |
| 85 | .map(|c| c.to_uppercase().chain(chars).collect()) |
| 86 | .unwrap_or_default() |
| 87 | } |
| 88 | |
| 89 | impl Popup for HelpPopup { |
| 90 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { |
| 91 | let inner = frame_popup( |
| 92 | frame, |
| 93 | area, |
| 94 | 70, |
| 95 | area.height.saturating_sub(4), |
| 96 | "Key bindings", |
| 97 | ctx, |
| 98 | ); |
| 99 | let theme = ctx.theme(); |
| 100 | let rows = &self.rows; |
| 101 | self.list.render( |
| 102 | frame, |
| 103 | inner, |
| 104 | Block::new(), |
| 105 | None, |
| 106 | &[Constraint::Length(24), Constraint::Fill(1)], |
| 107 | rows.len(), |
| 108 | true, |
| 109 | theme, |
| 110 | |i| match &rows[i] { |
| 111 | HelpRow::Heading(title) => Row::new([Span::styled(title.clone(), theme.accent())]), |
| 112 | HelpRow::Binding { keys, description } => Row::new([ |
| 113 | Span::styled(format!(" {keys}"), theme.text()), |
| 114 | Span::styled(*description, theme.dim()), |
| 115 | ]), |
| 116 | }, |
| 117 | ); |
| 118 | } |
| 119 | |
| 120 | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { |
| 121 | let keymap = &ctx.config.keymap; |
| 122 | match keymap.resolve(&[Context::Popup, Context::Navigation], key) { |
| 123 | Some(Action::Close) => PopupState::Closed, |
| 124 | Some(action) => { |
| 125 | self.list.navigate(action, self.rows.len()); |
| 126 | PopupState::Open |
| 127 | } |
| 128 | None if keymap.resolve(&[Context::Global], key) == Some(Action::Help) => { |
| 129 | PopupState::Closed |
| 130 | } |
| 131 | None => PopupState::Open, |
| 132 | } |
| 133 | } |
| 134 | } |