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                ("r".into(), "Refresh".into()),
110                ("b".into(), "Generate Bug Report".into()),
111            ],
112        },
113        HelpSection {
114            title: "Accounts Page".into(),
115            entries: vec![
116                ("n".into(), "New IMAP/SMTP Account".into()),
117                ("Enter".into(), "Edit Selected Account".into()),
118                ("t".into(), "Test Account".into()),
119                ("d".into(), "Set Default".into()),
120                ("r".into(), "Refresh Accounts".into()),
121            ],
122        },
123        HelpSection {
124            title: "Modals".into(),
125            entries: vec![
126                ("Help: j/k Ctrl-d/u".into(), "Scroll".into()),
127                ("Label Picker".into(), "Type, j/k, Enter, Esc".into()),
128                ("Compose Picker".into(), "Type, Tab, Enter, Esc".into()),
129                ("Attachments".into(), "j/k, Enter/o, d, Esc".into()),
130                ("Links".into(), "j/k, Enter/o open, y copy, Esc".into()),
131                (
132                    "Unsubscribe".into(),
133                    "Enter unsubscribe, a archive sender, Esc cancel".into(),
134                ),
135                (
136                    "Bulk Confirm".into(),
137                    "Enter/y confirm, Esc/n cancel".into(),
138                ),
139            ],
140        },
141    ];
142
143    sections.extend(command_sections());
144    sections
145}
146
147fn context_entries(state: &HelpModalState<'_>) -> Vec<(String, String)> {
148    let mut entries = vec![(
149        "Screen".into(),
150        match state.screen {
151            Screen::Mailbox => "Mailbox".into(),
152            Screen::Search => "Search".into(),
153            Screen::Rules => "Rules".into(),
154            Screen::Diagnostics => "Diagnostics".into(),
155            Screen::Accounts => "Accounts".into(),
156        },
157    )];
158
159    if state.screen == Screen::Mailbox {
160        entries.push((
161            "Pane".into(),
162            match state.active_pane {
163                ActivePane::Sidebar => "Sidebar".into(),
164                ActivePane::MailList => "Mail List".into(),
165                ActivePane::MessageView => "Message".into(),
166            },
167        ));
168    }
169
170    if state.selected_count > 0 {
171        entries.push((
172            "Selection".into(),
173            format!(
174                "{} selected: archive, delete, label, move, read/unread, star, Esc clears",
175                state.selected_count
176            ),
177        ));
178    }
179
180    entries
181}
182
183fn command_sections() -> Vec<HelpSection> {
184    let mut by_category: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
185    for command in default_commands() {
186        let shortcut = if command.shortcut.is_empty() {
187            "palette".to_string()
188        } else {
189            command.shortcut
190        };
191        by_category
192            .entry(command.category)
193            .or_default()
194            .push((shortcut, command.label));
195    }
196
197    by_category
198        .into_iter()
199        .map(|(category, mut entries)| {
200            entries.sort_by(|left, right| left.1.cmp(&right.1));
201            HelpSection {
202                title: format!("Commands: {category}"),
203                entries,
204            }
205        })
206        .collect()
207}
208
209fn render_sections(sections: &[HelpSection], theme: &crate::theme::Theme) -> Vec<Line<'static>> {
210    let mut lines = Vec::new();
211
212    for (index, section) in sections.iter().enumerate() {
213        if index > 0 {
214            lines.push(Line::from(""));
215        }
216        lines.push(Line::from(Span::styled(
217            section.title.clone(),
218            Style::default().fg(theme.accent).bold(),
219        )));
220        for (key, action) in &section.entries {
221            lines.push(Line::from(vec![
222                Span::styled(
223                    format!("{key:<20}"),
224                    Style::default().fg(theme.text_primary).bold(),
225                ),
226                Span::styled(action.clone(), Style::default().fg(theme.text_secondary)),
227            ]));
228        }
229    }
230
231    lines
232}
233
234fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
235    let vertical = Layout::default()
236        .direction(Direction::Vertical)
237        .constraints([
238            Constraint::Percentage((100 - percent_y) / 2),
239            Constraint::Percentage(percent_y),
240            Constraint::Percentage((100 - percent_y) / 2),
241        ])
242        .split(area);
243
244    Layout::default()
245        .direction(Direction::Horizontal)
246        .constraints([
247            Constraint::Percentage((100 - percent_x) / 2),
248            Constraint::Percentage(percent_x),
249            Constraint::Percentage((100 - percent_x) / 2),
250        ])
251        .split(vertical[1])[1]
252}
253
254#[cfg(test)]
255mod tests {
256    use super::{help_sections, HelpModalState};
257    use crate::app::{ActivePane, Screen};
258
259    #[test]
260    fn help_sections_cover_accounts_and_commands() {
261        let state = HelpModalState {
262            open: true,
263            screen: Screen::Accounts,
264            active_pane: &ActivePane::MailList,
265            selected_count: 2,
266            scroll_offset: 0,
267        };
268        let titles: Vec<String> = help_sections(&state)
269            .into_iter()
270            .map(|section| section.title)
271            .collect();
272        assert!(titles.contains(&"Accounts Page".to_string()));
273        assert!(titles
274            .iter()
275            .any(|title| title.starts_with("Commands: Accounts")));
276        assert!(titles
277            .iter()
278            .any(|title| title.starts_with("Commands: Mail")));
279    }
280}