Skip to main content

mxr_tui/ui/
help_modal.rs

1use crate::app::{ActivePane, Screen};
2use crate::keybindings::{all_bindings_for_context, ViewContext};
3use crate::ui::command_palette::default_commands;
4use ratatui::prelude::*;
5use ratatui::widgets::*;
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone)]
9struct HelpSection {
10    title: String,
11    entries: Vec<(String, String)>,
12}
13
14pub struct HelpModalState<'a> {
15    pub open: bool,
16    pub screen: Screen,
17    pub active_pane: &'a ActivePane,
18    pub selected_count: usize,
19    pub scroll_offset: u16,
20}
21
22pub fn draw(frame: &mut Frame, area: Rect, state: HelpModalState<'_>, theme: &crate::theme::Theme) {
23    if !state.open {
24        return;
25    }
26
27    let popup = centered_rect(88, 88, area);
28    frame.render_widget(Clear, popup);
29
30    let block = Block::bordered()
31        .title(" Help ")
32        .border_type(BorderType::Rounded)
33        .border_style(Style::default().fg(theme.accent))
34        .style(Style::default().bg(theme.modal_bg));
35    let inner = block.inner(popup);
36    frame.render_widget(block, popup);
37
38    let lines = render_sections(&help_sections(&state), theme);
39    let content_height = lines.len();
40    let paragraph = Paragraph::new(lines)
41        .scroll((state.scroll_offset, 0))
42        .wrap(Wrap { trim: false });
43    frame.render_widget(paragraph, inner);
44
45    let mut scrollbar_state =
46        ScrollbarState::new(content_height.saturating_sub(inner.height as usize))
47            .position(state.scroll_offset as usize);
48    frame.render_stateful_widget(
49        Scrollbar::default()
50            .orientation(ScrollbarOrientation::VerticalRight)
51            .thumb_style(Style::default().fg(theme.warning)),
52        inner,
53        &mut scrollbar_state,
54    );
55}
56
57fn help_sections(state: &HelpModalState<'_>) -> Vec<HelpSection> {
58    let mut sections = vec![
59        HelpSection {
60            title: "Global".into(),
61            entries: vec![
62                ("Ctrl-p".into(), "Command Palette".into()),
63                ("?".into(), "Toggle Help".into()),
64                ("Esc".into(), "Back / Close".into()),
65                ("q".into(), "Quit".into()),
66            ],
67        },
68        HelpSection {
69            title: "Current Context".into(),
70            entries: context_entries(state),
71        },
72        HelpSection {
73            title: "Mailbox List".into(),
74            entries: all_bindings_for_context(ViewContext::MailList),
75        },
76        HelpSection {
77            title: "Thread View".into(),
78            entries: all_bindings_for_context(ViewContext::ThreadView),
79        },
80        HelpSection {
81            title: "Single Message".into(),
82            entries: all_bindings_for_context(ViewContext::MessageView),
83        },
84        HelpSection {
85            title: "Search Page".into(),
86            entries: vec![
87                ("/".into(), "Edit Query".into()),
88                ("Enter".into(), "Run Query / Open Result".into()),
89                ("o".into(), "Open In Mailbox".into()),
90                ("j / k".into(), "Move Results".into()),
91                ("Esc".into(), "Return To Mailbox".into()),
92            ],
93        },
94        HelpSection {
95            title: "Rules Page".into(),
96            entries: vec![
97                ("j / k".into(), "Move Rules".into()),
98                ("n".into(), "New Rule".into()),
99                ("E".into(), "Edit Rule".into()),
100                ("e".into(), "Enable / Disable".into()),
101                ("D".into(), "Dry Run".into()),
102                ("H".into(), "History".into()),
103                ("#".into(), "Delete Rule".into()),
104            ],
105        },
106        HelpSection {
107            title: "Diagnostics Page".into(),
108            entries: vec![
109                ("Tab / Shift-Tab".into(), "Select Pane".into()),
110                ("j / k Ctrl-d/u".into(), "Scroll Selected Pane".into()),
111                ("Enter / o".into(), "Toggle Fullscreen Pane".into()),
112                ("d".into(), "Open Selected Pane Details".into()),
113                ("r".into(), "Refresh".into()),
114                ("b".into(), "Generate Bug Report".into()),
115                ("L".into(), "Open Log File".into()),
116            ],
117        },
118        HelpSection {
119            title: "Accounts Page".into(),
120            entries: vec![
121                ("n".into(), "New IMAP/SMTP Account".into()),
122                ("Enter".into(), "Edit Selected Account".into()),
123                ("t".into(), "Test Account".into()),
124                ("d".into(), "Set Default".into()),
125                ("r".into(), "Refresh Accounts".into()),
126            ],
127        },
128        HelpSection {
129            title: "Modals".into(),
130            entries: vec![
131                ("Help: j/k Ctrl-d/u".into(), "Scroll".into()),
132                ("Label Picker".into(), "Type, j/k, Enter, Esc".into()),
133                ("Compose Picker".into(), "Type, Tab, Enter, Esc".into()),
134                ("Attachments".into(), "j/k, Enter/o, d, Esc".into()),
135                ("Links".into(), "j/k, Enter/o open, y copy, Esc".into()),
136                (
137                    "Unsubscribe".into(),
138                    "Enter unsubscribe, a archive sender, Esc cancel".into(),
139                ),
140                (
141                    "Bulk Confirm".into(),
142                    "Enter/y confirm, Esc/n cancel".into(),
143                ),
144            ],
145        },
146    ];
147
148    sections.extend(command_sections());
149    sections
150}
151
152fn context_entries(state: &HelpModalState<'_>) -> Vec<(String, String)> {
153    let mut entries = vec![(
154        "Screen".into(),
155        match state.screen {
156            Screen::Mailbox => "Mailbox".into(),
157            Screen::Search => "Search".into(),
158            Screen::Rules => "Rules".into(),
159            Screen::Diagnostics => "Diagnostics".into(),
160            Screen::Accounts => "Accounts".into(),
161        },
162    )];
163
164    if state.screen == Screen::Mailbox {
165        entries.push((
166            "Pane".into(),
167            match state.active_pane {
168                ActivePane::Sidebar => "Sidebar".into(),
169                ActivePane::MailList => "Mail List".into(),
170                ActivePane::MessageView => "Message".into(),
171            },
172        ));
173    }
174
175    if state.selected_count > 0 {
176        entries.push((
177            "Selection".into(),
178            format!(
179                "{} selected: archive, delete, label, move, read/unread, star, Esc clears",
180                state.selected_count
181            ),
182        ));
183    }
184
185    entries
186}
187
188fn command_sections() -> Vec<HelpSection> {
189    let mut by_category: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
190    for command in default_commands() {
191        let shortcut = if command.shortcut.is_empty() {
192            "palette".to_string()
193        } else {
194            command.shortcut
195        };
196        by_category
197            .entry(command.category)
198            .or_default()
199            .push((shortcut, command.label));
200    }
201
202    by_category
203        .into_iter()
204        .map(|(category, mut entries)| {
205            entries.sort_by(|left, right| left.1.cmp(&right.1));
206            HelpSection {
207                title: format!("Commands: {category}"),
208                entries,
209            }
210        })
211        .collect()
212}
213
214fn render_sections(sections: &[HelpSection], theme: &crate::theme::Theme) -> Vec<Line<'static>> {
215    let mut lines = Vec::new();
216
217    for (index, section) in sections.iter().enumerate() {
218        if index > 0 {
219            lines.push(Line::from(""));
220        }
221        lines.push(Line::from(Span::styled(
222            section.title.clone(),
223            Style::default().fg(theme.accent).bold(),
224        )));
225        for (key, action) in &section.entries {
226            lines.push(Line::from(vec![
227                Span::styled(
228                    format!("{key:<20}"),
229                    Style::default().fg(theme.text_primary).bold(),
230                ),
231                Span::styled(action.clone(), Style::default().fg(theme.text_secondary)),
232            ]));
233        }
234    }
235
236    lines
237}
238
239fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
240    let vertical = Layout::default()
241        .direction(Direction::Vertical)
242        .constraints([
243            Constraint::Percentage((100 - percent_y) / 2),
244            Constraint::Percentage(percent_y),
245            Constraint::Percentage((100 - percent_y) / 2),
246        ])
247        .split(area);
248
249    Layout::default()
250        .direction(Direction::Horizontal)
251        .constraints([
252            Constraint::Percentage((100 - percent_x) / 2),
253            Constraint::Percentage(percent_x),
254            Constraint::Percentage((100 - percent_x) / 2),
255        ])
256        .split(vertical[1])[1]
257}
258
259#[cfg(test)]
260mod tests {
261    use super::{help_sections, HelpModalState};
262    use crate::app::{ActivePane, Screen};
263
264    #[test]
265    fn help_sections_cover_accounts_and_commands() {
266        let state = HelpModalState {
267            open: true,
268            screen: Screen::Accounts,
269            active_pane: &ActivePane::MailList,
270            selected_count: 2,
271            scroll_offset: 0,
272        };
273        let titles: Vec<String> = help_sections(&state)
274            .into_iter()
275            .map(|section| section.title)
276            .collect();
277        assert!(titles.contains(&"Accounts Page".to_string()));
278        assert!(titles
279            .iter()
280            .any(|title| title.starts_with("Commands: Accounts")));
281        assert!(titles
282            .iter()
283            .any(|title| title.starts_with("Commands: Mail")));
284    }
285}