Skip to main content

wiki_tui/components/
help_popup.rs

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 // +6 for section headers and spacing
154    }
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        // Adjust scroll bounds
282        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}