Skip to main content

metarepo_core/tui/widgets/
help.rs

1//! Help panel widget
2
3use crate::tui::modes::Mode;
4use ratatui::{
5    buffer::Buffer,
6    layout::Rect,
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Paragraph, Widget},
10};
11
12/// Help panel showing keybindings
13pub struct HelpPanel {
14    /// Current mode (affects which help to show)
15    pub mode: Mode,
16}
17
18impl HelpPanel {
19    pub fn new(mode: Mode) -> Self {
20        Self { mode }
21    }
22
23    /// Get help text for the current mode
24    fn get_help_lines(&self) -> Vec<Line<'_>> {
25        let title = Line::from(vec![
26            Span::styled("Keybindings", Style::default().add_modifier(Modifier::BOLD)),
27            Span::raw(" - Press "),
28            Span::styled("?", Style::default().fg(Color::Cyan)),
29            Span::raw(" to close"),
30        ]);
31
32        let mut lines = vec![title, Line::from("")];
33
34        match self.mode {
35            Mode::Normal => {
36                lines.extend(vec![
37                    Line::from(vec![Span::styled(
38                        "Navigation:",
39                        Style::default().fg(Color::Yellow),
40                    )]),
41                    Line::from("  h/j/k/l or ←/↓/↑/→  Move cursor"),
42                    Line::from("  g / G               Jump to top / bottom"),
43                    Line::from("  Ctrl+u / Ctrl+d     Page up / down"),
44                    Line::from(""),
45                    Line::from(vec![Span::styled(
46                        "Tree:",
47                        Style::default().fg(Color::Yellow),
48                    )]),
49                    Line::from("  Enter or o          Toggle expand/collapse"),
50                    Line::from("  O                   Expand node"),
51                    Line::from("  C                   Collapse node"),
52                    Line::from(""),
53                    Line::from(vec![Span::styled(
54                        "Editing:",
55                        Style::default().fg(Color::Yellow),
56                    )]),
57                    Line::from("  i                   Enter insert mode"),
58                    Line::from("  v                   Enter visual mode"),
59                    Line::from("  d                   Delete node"),
60                    Line::from("  x                   Delete character"),
61                    Line::from(""),
62                    Line::from(vec![Span::styled(
63                        "Commands:",
64                        Style::default().fg(Color::Yellow),
65                    )]),
66                    Line::from("  :                   Enter command mode"),
67                    Line::from("  Ctrl+w              Save"),
68                    Line::from("  Ctrl+q              Quit"),
69                    Line::from("  ?                   Show/hide help"),
70                ]);
71            }
72            Mode::Insert => {
73                lines.extend(vec![
74                    Line::from(vec![Span::styled(
75                        "Insert Mode:",
76                        Style::default().fg(Color::Green),
77                    )]),
78                    Line::from("  Esc                 Return to normal mode"),
79                    Line::from("  Typing              Insert text"),
80                    Line::from("  Backspace           Delete previous char"),
81                    Line::from("  Enter               New line / confirm"),
82                    Line::from("  ←/→/↑/↓             Navigate"),
83                ]);
84            }
85            Mode::Visual => {
86                lines.extend(vec![
87                    Line::from(vec![Span::styled(
88                        "Visual Mode:",
89                        Style::default().fg(Color::Yellow),
90                    )]),
91                    Line::from("  Esc or v            Return to normal mode"),
92                    Line::from("  j/k or ↓/↑          Extend selection"),
93                    Line::from("  Ctrl+a              Select all"),
94                    Line::from("  d or x              Delete selection"),
95                ]);
96            }
97            Mode::Command => {
98                lines.extend(vec![
99                    Line::from(vec![Span::styled(
100                        "Command Mode:",
101                        Style::default().fg(Color::Magenta),
102                    )]),
103                    Line::from("  Esc                 Cancel command"),
104                    Line::from("  Enter               Execute command"),
105                    Line::from(""),
106                    Line::from(vec![Span::styled(
107                        "Commands:",
108                        Style::default().fg(Color::Yellow),
109                    )]),
110                    Line::from("  :w or :write        Save changes"),
111                    Line::from("  :q or :quit         Quit (fails if modified)"),
112                    Line::from("  :q! or :quit!       Force quit (discard changes)"),
113                    Line::from("  :wq or :x           Save and quit"),
114                ]);
115            }
116        }
117
118        lines
119    }
120}
121
122impl Widget for HelpPanel {
123    fn render(self, area: Rect, buf: &mut Buffer) {
124        let lines = self.get_help_lines();
125
126        let block = Block::default()
127            .borders(Borders::ALL)
128            .border_style(Style::default().fg(Color::Cyan))
129            .title(" Help ");
130
131        let paragraph = Paragraph::new(lines).block(block);
132
133        Widget::render(paragraph, area, buf);
134    }
135}
136
137/// A group of related keybindings shown under a heading in [`KeybindingHelp`].
138pub struct HelpSection {
139    /// Heading shown above the entries (e.g. "Navigation").
140    pub heading: String,
141    /// `(keys, description)` rows, e.g. `("j / ↓", "Move down")`.
142    pub entries: Vec<(String, String)>,
143}
144
145impl HelpSection {
146    pub fn new(heading: impl Into<String>, entries: Vec<(&str, &str)>) -> Self {
147        Self {
148            heading: heading.into(),
149            entries: entries
150                .into_iter()
151                .map(|(k, d)| (k.to_string(), d.to_string()))
152                .collect(),
153        }
154    }
155}
156
157/// A data-driven keybinding help overlay. Unlike [`HelpPanel`] (which is tied to
158/// the modal `Mode` enum), this renders whatever sections the caller supplies,
159/// so each TUI surface can show its own real keymap. Meant to be drawn over the
160/// UI in a centered popup (clear the area first).
161pub struct KeybindingHelp {
162    title: String,
163    sections: Vec<HelpSection>,
164}
165
166impl KeybindingHelp {
167    pub fn new(title: impl Into<String>, sections: Vec<HelpSection>) -> Self {
168        Self {
169            title: title.into(),
170            sections,
171        }
172    }
173}
174
175impl Widget for KeybindingHelp {
176    fn render(self, area: Rect, buf: &mut Buffer) {
177        // Widest key column across all sections, for aligned descriptions.
178        let key_width = self
179            .sections
180            .iter()
181            .flat_map(|s| s.entries.iter())
182            .map(|(k, _)| k.chars().count())
183            .max()
184            .unwrap_or(0);
185
186        let mut lines: Vec<Line> = vec![
187            Line::from(vec![
188                Span::raw("Press "),
189                Span::styled("?", Style::default().fg(Color::Cyan)),
190                Span::raw(" or "),
191                Span::styled("Esc", Style::default().fg(Color::Cyan)),
192                Span::raw(" to close"),
193            ]),
194            Line::from(""),
195        ];
196
197        for (i, section) in self.sections.iter().enumerate() {
198            if i > 0 {
199                lines.push(Line::from(""));
200            }
201            lines.push(Line::from(Span::styled(
202                section.heading.clone(),
203                Style::default()
204                    .fg(Color::Yellow)
205                    .add_modifier(Modifier::BOLD),
206            )));
207            for (keys, desc) in &section.entries {
208                let pad = " ".repeat(key_width.saturating_sub(keys.chars().count()));
209                lines.push(Line::from(vec![
210                    Span::raw("  "),
211                    Span::styled(keys.clone(), Style::default().fg(Color::Cyan)),
212                    Span::raw(format!("{pad}   ")),
213                    Span::raw(desc.clone()),
214                ]));
215            }
216        }
217
218        let block = Block::default()
219            .borders(Borders::ALL)
220            .border_style(Style::default().fg(Color::Cyan))
221            .title(format!(" {} ", self.title));
222
223        Widget::render(Paragraph::new(lines).block(block), area, buf);
224    }
225}