1use std::{cmp::max, sync::Arc};
2
3use itertools::Itertools;
4use ratatui::{
5 layout::{Constraint, Flex, Layout, Rect},
6 style::{Color, Style, Stylize},
7 text::{Line, Span},
8 widgets::{Clear, Paragraph},
9};
10
11use crate::{
12 action::{Action, ActionResult},
13 config::{Config, Keybinding, Theme},
14 terminal::Frame,
15 ui::centered_rect,
16};
17
18use super::Component;
19
20struct Binding {
21 pub bindings: Box<[Box<str>]>,
22 pub name: Box<str>,
23}
24
25impl Binding {
26 pub fn keys_len(&self) -> usize {
27 self.bindings.iter().fold(0, |acc, elm| acc + elm.len() + 2) - 2
28 }
29
30 pub fn to_line(&self, gap: usize, highlight: Color) -> Line<'_> {
31 Itertools::intersperse(
32 self.bindings
33 .iter()
34 .map(|b| Span::styled(b.as_ref(), Style::default().fg(highlight))),
35 Span::raw(", "),
36 )
37 .take(self.bindings.len() * 2 - 1)
38 .chain([
39 Span::raw((self.keys_len()..gap).map(|_| ".").collect::<String>()),
40 Span::raw(" "),
41 Span::raw(self.name.as_ref()),
42 ])
43 .collect()
44 }
45
46 pub fn from_keys(key: &Keybinding, desc: &str) -> Self {
47 let bindings = key
48 .bindings()
49 .iter()
50 .map(|b| b.to_string().into())
51 .collect();
52 Self {
53 bindings,
54 name: desc.into(),
55 }
56 }
57}
58
59pub struct HelpPopupComponent {
60 line: u16,
61
62 global_bindings_list: Arc<[Binding]>,
63 search_bindings_list: Arc<[Binding]>,
64 page_bindings_list: Arc<[Binding]>,
65 config: Arc<Config>,
66 theme: Arc<Theme>,
67}
68
69impl HelpPopupComponent {
70 pub fn new(config: Arc<Config>, theme: Arc<Theme>) -> Self {
71 macro_rules! convert_binding {
72 ($binding:expr, $desc:literal) => {
73 Binding::from_keys(&$binding, $desc)
74 };
75 ($binding:expr) => {
76 Binding::from_keys(&$binding, {
77 let string_name = stringify!($binding);
78 &match string_name.rsplit_once('.') {
79 None => string_name,
80 Some((_, name)) => name,
81 }
82 .replace('_', " ")
83 })
84 };
85 }
86
87 let global_bindings_list = vec![
88 convert_binding!(config.bindings.global.scroll_down, "scroll down"),
89 convert_binding!(config.bindings.global.scroll_up, "scroll up"),
90 convert_binding!(config.bindings.global.scroll_to_top, "scroll to top"),
91 convert_binding!(config.bindings.global.scroll_to_bottom, "scroll to bottom"),
92 convert_binding!(config.bindings.global.half_down, "half page down"),
93 convert_binding!(config.bindings.global.half_up, "half page up"),
94 convert_binding!(config.bindings.global.unselect_scroll, "unselect scroll"),
95 convert_binding!(config.bindings.global.enter_search_bar, "enter search bar"),
96 convert_binding!(config.bindings.global.exit_search_bar, "exit search bar"),
97 convert_binding!(
98 config.bindings.global.switch_context_search,
99 "switch to search"
100 ),
101 convert_binding!(config.bindings.global.switch_context_page, "switch to page"),
102 convert_binding!(
103 config.bindings.global.toggle_search_language_selection,
104 "toggle search language"
105 ),
106 convert_binding!(config.bindings.global.toggle_logger, "toggle logger"),
107 convert_binding!(config.bindings.global.help, "show this help"),
108 convert_binding!(config.bindings.global.pop_popup, "close popup"),
109 convert_binding!(config.bindings.global.quit, "quit"),
110 ]
111 .into();
112
113 let search_bindings_list = vec![convert_binding!(
114 config.bindings.search.continue_search,
115 "continue search"
116 )]
117 .into();
118
119 let page_bindings_list = vec![
120 convert_binding!(config.bindings.page.pop_page, "go back"),
121 convert_binding!(config.bindings.page.jump_to_header, "jump to header"),
122 convert_binding!(config.bindings.page.select_first_link, "select first link"),
123 convert_binding!(config.bindings.page.select_last_link, "select last link"),
124 convert_binding!(
125 config.bindings.page.select_prev_link,
126 "select previous link"
127 ),
128 convert_binding!(config.bindings.page.select_next_link, "select next link"),
129 convert_binding!(config.bindings.page.open_link, "open link"),
130 convert_binding!(
131 config.bindings.page.toggle_page_language_selection,
132 "toggle page language"
133 ),
134 convert_binding!(config.bindings.page.toggle_zen_mode, "toggle zen mode"),
135 convert_binding!(config.bindings.page.toggle_toc, "toggle table of contents"),
136 ]
137 .into();
138
139 Self {
140 line: 0,
141 global_bindings_list,
142 search_bindings_list,
143 page_bindings_list,
144 config,
145 theme,
146 }
147 }
148
149 pub fn data_size(&self) -> u16 {
150 (self.global_bindings_list.len()
151 + self.search_bindings_list.len()
152 + self.page_bindings_list.len()
153 + 6) as u16 }
155}
156
157impl Component for HelpPopupComponent {
158 fn handle_key_events(&mut self, key: crossterm::event::KeyEvent) -> ActionResult {
159 if self.config.bindings.global.pop_popup.matches_event(key)
160 || self.config.bindings.global.quit.matches_event(key)
161 || self.config.bindings.global.help.matches_event(key)
162 {
163 return Action::PopPopup.into();
164 }
165
166 if self.config.bindings.global.scroll_up.matches_event(key) {
167 return Action::ScrollUp(1).into();
168 }
169
170 if self.config.bindings.global.scroll_down.matches_event(key) {
171 return Action::ScrollDown(1).into();
172 }
173
174 if self.config.bindings.global.scroll_to_top.matches_event(key) {
175 return Action::ScrollToTop.into();
176 }
177
178 if self
179 .config
180 .bindings
181 .global
182 .scroll_to_bottom
183 .matches_event(key)
184 {
185 return Action::ScrollToBottom.into();
186 }
187
188 if self.config.bindings.global.half_up.matches_event(key) {
189 return Action::ScrollHalfUp.into();
190 }
191
192 if self.config.bindings.global.half_down.matches_event(key) {
193 return Action::ScrollHalfDown.into();
194 }
195
196 ActionResult::Ignored
197 }
198
199 fn update(&mut self, action: Action) -> ActionResult {
200 match action {
201 Action::ScrollUp(n) => {
202 self.line = self.line.saturating_sub(n);
203 ActionResult::consumed()
204 }
205 Action::ScrollDown(n) => {
206 self.line = self.line.saturating_add(n);
207 ActionResult::consumed()
208 }
209 Action::ScrollToTop => {
210 self.line = 0;
211 ActionResult::consumed()
212 }
213 Action::ScrollToBottom => {
214 self.line = self.data_size().saturating_sub(1);
215 ActionResult::consumed()
216 }
217 Action::ScrollHalfUp => {
218 self.line = self.line.saturating_sub(10);
219 ActionResult::consumed()
220 }
221 Action::ScrollHalfDown => {
222 self.line = self.line.saturating_add(10);
223 ActionResult::consumed()
224 }
225 Action::Quit => Action::PopPopup.into(),
226 _ => ActionResult::Ignored,
227 }
228 }
229
230 fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
231 let popup_block = self
232 .theme
233 .default_block()
234 .title("Help - Keybindings")
235 .title_bottom(Line::from("<ESC> Close").right_aligned())
236 .style(Style::default().bg(self.theme.bg));
237 let area = centered_rect(area, 80, 90);
238
239 f.render_widget(Clear, area);
240 f.render_widget(popup_block, area);
241
242 let [text_area] = Layout::vertical([Constraint::Percentage(100)])
243 .margin(1)
244 .flex(Flex::Center)
245 .areas(area);
246
247 let gap = self
248 .global_bindings_list
249 .iter()
250 .chain(self.search_bindings_list.iter())
251 .chain(self.page_bindings_list.iter())
252 .fold(0, |acc, elm| max(acc, elm.keys_len()))
253 + 2;
254 let highlight_color = self.theme.search_title_fg;
255
256 macro_rules! to_line {
257 ($bindings:expr) => {
258 $bindings
259 .iter()
260 .map(|x| x.to_line(gap, highlight_color))
261 .collect()
262 };
263 }
264
265 let consolidated_list: Vec<Line> = [
266 vec![Line::raw("Global Keybindings").bold().underlined()],
267 to_line!(self.global_bindings_list),
268 vec![
269 Line::default(),
270 Line::raw("Search Keybindings").bold().underlined(),
271 ],
272 to_line!(self.search_bindings_list),
273 vec![
274 Line::default(),
275 Line::raw("Page Keybindings").bold().underlined(),
276 ],
277 to_line!(self.page_bindings_list),
278 ]
279 .concat();
280
281 if self.data_size() <= text_area.height {
283 self.line = 0;
284 } else if self.line > self.data_size().saturating_sub(text_area.height) {
285 self.line = self.data_size().saturating_sub(text_area.height);
286 }
287
288 let paragraph_widget = Paragraph::new(consolidated_list).scroll((self.line, 0));
289
290 f.render_widget(paragraph_widget, text_area);
291 }
292}