Skip to main content

wiki_tui/
app.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use crossterm::event::{KeyCode, KeyEvent};
5use ratatui::{
6    prelude::{Constraint, Direction, Layout, Rect},
7    style::Style,
8    widgets::Block,
9};
10use tracing::warn;
11
12use tokio::sync::mpsc;
13
14use crate::{
15    action::{Action, ActionPacket, ActionResult},
16    components::{
17        help_popup::HelpPopupComponent,
18        logger::LoggerComponent,
19        message_popup::MessagePopupComponent,
20        page_viewer::PageViewer,
21        search::SearchComponent,
22        search_bar::{SearchBarComponent, SEARCH_BAR_HEIGTH},
23        search_language_popup::SearchLanguageSelectionComponent,
24        Component,
25    },
26    config::{Config, Theme, ZenModeComponents},
27    has_modifier,
28    page_loader::PageLoader,
29    terminal::Frame,
30};
31use wiki_api::page::Link;
32
33const CONTEXT_SEARCH: u8 = 0;
34const CONTEXT_PAGE: u8 = 1;
35
36#[derive(Default)]
37pub struct AppComponent {
38    search: SearchComponent,
39    page: PageViewer,
40    logger: LoggerComponent,
41    search_bar: SearchBarComponent,
42    page_loader: Option<PageLoader>,
43
44    is_logger: bool,
45
46    popups: Vec<Box<dyn Component + Send>>,
47    config: Arc<Config>,
48    theme: Arc<Theme>,
49
50    context: u8,
51    prev_context: u8,
52
53    action_tx: Option<mpsc::UnboundedSender<Action>>,
54}
55
56impl AppComponent {
57    fn switch_context(&mut self, context: u8) {
58        self.prev_context = context;
59        std::mem::swap(&mut self.prev_context, &mut self.context);
60    }
61
62    fn show_page_language(&mut self) {
63        let selection_widget = self.page.get_page_language_selection_popup();
64        self.popups.push(Box::new(selection_widget));
65    }
66
67    fn render_search_bar(&mut self, f: &mut Frame<'_>, area: Rect) -> Rect {
68        let (search_bar_area, area) = {
69            let chunks = Layout::default()
70                .direction(Direction::Vertical)
71                .constraints([
72                    Constraint::Min(SEARCH_BAR_HEIGTH),
73                    Constraint::Percentage(100),
74                ])
75                .split(area);
76            (chunks[0], chunks[1])
77        };
78
79        self.search_bar.render(f, search_bar_area);
80        area
81    }
82}
83
84impl Component for AppComponent {
85    fn init(
86        &mut self,
87        action_tx: mpsc::UnboundedSender<Action>,
88        config: Arc<Config>,
89        theme: Arc<Theme>,
90    ) -> Result<()> {
91        self.search
92            .init(action_tx.clone(), config.clone(), theme.clone())?;
93        self.page
94            .init(action_tx.clone(), config.clone(), theme.clone())?;
95        self.search_bar
96            .init(action_tx.clone(), config.clone(), theme.clone())?;
97
98        self.page_loader = Some(PageLoader::new(config.clone(), action_tx.clone()));
99
100        action_tx.send(Action::EnterSearchBar).unwrap();
101        self.action_tx = Some(action_tx);
102
103        self.config = config;
104        self.theme = theme;
105
106        Ok(())
107    }
108    fn handle_key_events(&mut self, key: KeyEvent) -> ActionResult {
109        // we need to always handle CTRL-C
110        if matches!(key.code, KeyCode::Char('c') if has_modifier!(key, Modifier::CONTROL)) {
111            return ActionPacket::single(Action::PageViewer(
112                crate::action::PageViewerAction::SaveCache,
113            ))
114            .action(Action::Quit)
115            .into();
116        }
117
118        if let Some(ref mut popup) = self.popups.last_mut() {
119            let result = popup.handle_key_events(key);
120            if result.is_consumed() {
121                return result;
122            }
123        }
124
125        if self.search_bar.is_focussed {
126            return self.search_bar.handle_key_events(key);
127        }
128
129        let result = match self.context {
130            CONTEXT_SEARCH => self.search.handle_key_events(key),
131            CONTEXT_PAGE => self.page.handle_key_events(key),
132            _ => {
133                warn!("unknown context");
134                ActionResult::Ignored
135            }
136        };
137
138        if result.is_consumed() {
139            return result;
140        }
141
142        let global_bindings = &self.config.bindings.global;
143        macro_rules! match_bindings {
144            ($($bind:ident => $action:expr),+) => {
145                $(if global_bindings.$bind.matches_event(key) {
146                    return $action.into();
147                })+
148            };
149        }
150
151        match_bindings!(
152            quit => ActionPacket::single(Action::PageViewer(crate::action::PageViewerAction::SaveCache))
153                .action(Action::Quit),
154            pop_popup => Action::PopPopup,
155
156            toggle_logger => Action::ToggleShowLogger,
157
158            switch_context_search => Action::SwitchContextSearch,
159            switch_context_page => Action::SwitchContextPage,
160
161            scroll_down => Action::ScrollDown(1),
162            scroll_up => Action::ScrollUp(1),
163
164            scroll_to_top => Action::ScrollToTop,
165            scroll_to_bottom => Action::ScrollToBottom,
166
167            half_up => Action::ScrollHalfUp,
168            half_down => Action::ScrollHalfDown,
169
170            unselect_scroll => Action::UnselectScroll,
171            enter_search_bar => Action::EnterSearchBar,
172
173            toggle_search_language_selection => {
174                self.popups
175                    .push(Box::new(SearchLanguageSelectionComponent::new(
176                        self.config.clone(),
177                        self.theme.clone(),
178                    )));
179                ActionResult::consumed()
180            },
181
182            help => Action::ShowHelp
183        );
184
185        ActionResult::Ignored
186    }
187
188    fn update(&mut self, action: Action) -> ActionResult {
189        // global actions
190        match action {
191            Action::PopPopup => {
192                self.popups.pop();
193            }
194
195            Action::ToggleShowLogger => self.is_logger = !self.is_logger,
196            Action::ShowPageLanguageSelection => self.show_page_language(),
197            Action::ShowHelp => {
198                self.popups.push(Box::new(HelpPopupComponent::new(
199                    self.config.clone(),
200                    self.theme.clone(),
201                )));
202            }
203
204            Action::SwitchContextSearch => self.switch_context(CONTEXT_SEARCH),
205            Action::SwitchContextPage => self.switch_context(CONTEXT_PAGE),
206            Action::SwitchPreviousContext => self.switch_context(self.prev_context),
207
208            Action::EnterSearchBar => self.search_bar.is_focussed = true,
209            Action::ExitSearchBar => self.search_bar.is_focussed = false,
210            Action::ClearSearchBar => self.search_bar.clear(),
211            Action::SubmitSearchBar => {
212                return ActionPacket::default()
213                    .action(Action::ExitSearchBar)
214                    .action(Action::SwitchContextSearch)
215                    .action(self.search_bar.submit())
216                    .into()
217            }
218
219            Action::TryLoadPage(title, language, endpoint) => {
220                return self
221                    .page
222                    .update(Action::TryLoadPage(title, language, endpoint));
223            }
224            Action::LoadSearchResult(result) => {
225                // Use TryLoadPage to check cache first
226                return Action::TryLoadPage(result.title, result.language, result.endpoint).into();
227            }
228            Action::LoadLink(link) => match link {
229                Link::Internal(data) => {
230                    return Action::TryLoadPage(data.page, data.language, data.endpoint).into();
231                }
232                _ => self.page_loader.as_ref().unwrap().load_link(link),
233            },
234            Action::LoadLangaugeLink(link) => {
235                return Action::TryLoadPage(link.title, link.language, link.endpoint).into();
236            }
237
238            Action::PopupMessage(title, content) => self.popups.push(Box::new(
239                MessagePopupComponent::new_raw(title, content, self.theme.clone()),
240            )),
241            Action::PopupError(error) => self.popups.push(Box::new(
242                MessagePopupComponent::new_error(error, self.theme.clone()),
243            )),
244            Action::PopupDialog(title, content, cb) => {
245                self.popups
246                    .push(Box::new(MessagePopupComponent::new_confirmation(
247                        title,
248                        content,
249                        *cb,
250                        self.theme.clone(),
251                    )))
252            }
253            _ => {
254                if let Some(ref mut popup) = self.popups.last_mut() {
255                    let result = popup.update(action.clone());
256                    if result.is_consumed() {
257                        return result;
258                    }
259                }
260
261                if matches!(action, Action::PageViewer(_)) {
262                    return self.page.update(action);
263                }
264
265                if matches!(action, Action::Search(_)) {
266                    return self.search.update(action);
267                }
268
269                let result = match self.context {
270                    CONTEXT_SEARCH => self.search.update(action.clone()),
271                    CONTEXT_PAGE => self.page.update(action.clone()),
272                    _ => {
273                        warn!("unknown context");
274                        return ActionResult::Ignored;
275                    }
276                };
277                if result.is_consumed() {
278                    return result;
279                }
280            }
281        };
282
283        ActionResult::consumed()
284    }
285
286    fn render(&mut self, f: &mut Frame<'_>, mut area: Rect) {
287        f.render_widget(
288            Block::default().style(Style::default().bg(self.theme.bg)),
289            area,
290        );
291
292        // don't render the search bar when we're in zen-mode and the config doesn't include the
293        // search bar in the zen-mode settings
294        match self.page.current_page() {
295            // always render the searchbar if its focussed
296            Some(_) if self.search_bar.is_focussed => area = self.render_search_bar(f, area),
297            Some(page) if self.context == CONTEXT_PAGE => {
298                if !page.is_zen_mode()
299                    || self
300                        .config
301                        .page
302                        .zen_mode
303                        .contains(ZenModeComponents::SEARCH_BAR)
304                {
305                    area = self.render_search_bar(f, area);
306                }
307            }
308            _ => area = self.render_search_bar(f, area),
309        }
310
311        if self.is_logger {
312            let chunks = Layout::default()
313                .direction(Direction::Horizontal)
314                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
315                .split(area);
316            self.logger.render(f, chunks[1]);
317            area = chunks[0];
318        };
319
320        match self.context {
321            CONTEXT_SEARCH => self.search.render(f, area),
322            CONTEXT_PAGE => self.page.render(f, area),
323            _ => warn!("unknown context"),
324        }
325
326        if let Some(ref mut popup) = self.popups.last_mut() {
327            popup.render(f, area);
328        }
329    }
330}