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