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 §ion.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}