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 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 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 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 match self.page.current_page() {
295 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}