Skip to main content

wiki_tui/components/
search.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use crossterm::event::{KeyCode, KeyEvent};
5use ratatui::{
6    prelude::{Alignment, Constraint, Direction, Layout, Rect},
7    style::{Color, Modifier, Style, Stylize},
8    text::{Line, Span, Text},
9    widgets::{HighlightSpacing, List, ListItem, Wrap},
10};
11use tokio::sync::mpsc;
12use tracing::{error, info, warn};
13use wiki_api::{
14    languages::Language,
15    search::{Search as ApiSearch, SearchContinue, SearchInfo, SearchRequest, SearchResult},
16    Endpoint,
17};
18
19use crate::{
20    action::{Action, ActionPacket, ActionResult, SearchAction},
21    config::{Config, Theme},
22    terminal::Frame,
23    ui::{centered_rect, ScrollBehaviour, StatefulList},
24};
25
26use super::Component;
27
28#[derive(Default, Debug, PartialEq, Eq, Clone)]
29pub enum Mode {
30    #[default]
31    NoSearch,
32    FinishedSearch,
33
34    Suggestion,
35
36    Searching,
37}
38
39pub struct SearchComponent {
40    mode: Mode,
41    pub endpoint: Option<Endpoint>,
42    pub language: Option<Language>,
43
44    search_results: StatefulList<SearchResult>,
45    search_info: Option<SearchInfo>,
46    continue_search: Option<SearchContinue>,
47
48    config: Arc<Config>,
49    theme: Arc<Theme>,
50
51    action_tx: Option<mpsc::UnboundedSender<Action>>,
52}
53
54impl Default for SearchComponent {
55    fn default() -> SearchComponent {
56        SearchComponent {
57            mode: Mode::default(),
58            endpoint: None,
59            language: None,
60
61            search_results: StatefulList::with_items(Vec::new())
62                .scroll_behavior(ScrollBehaviour::StickToEnds),
63            search_info: None,
64            continue_search: None,
65
66            config: Arc::new(Config::default()),
67            theme: Arc::new(Theme::default()),
68
69            action_tx: None,
70        }
71    }
72}
73
74impl SearchComponent {
75    fn build_search(&self, query: String) -> Result<SearchRequest> {
76        let api_config = &self.config.api;
77
78        let endpoint = self.endpoint.clone().unwrap_or(api_config.endpoint.clone());
79        let language = self.language.unwrap_or(api_config.language);
80
81        Ok(ApiSearch::builder()
82            .query(query)
83            .endpoint(endpoint)
84            .language(language)
85            .limit(api_config.search_limit)
86            .qiprofile(api_config.search_qiprofile.clone())
87            .search_type(api_config.search_type.clone())
88            .info(api_config.search_info.clone())
89            .rewrites(api_config.search_rewrites)
90            .sort_order(api_config.search_sort_order.clone()))
91    }
92
93    fn start_search(&mut self, query: String) -> ActionResult {
94        let tx = self.action_tx.clone().unwrap();
95        let search_request = match self.build_search(query) {
96            Ok(search_request) => search_request,
97            Err(error) => {
98                error!("Unable to build the search request: {:?}", error);
99                return ActionResult::consumed();
100            }
101        };
102        tokio::spawn(async move {
103            tx.send(Action::Search(SearchAction::ChangeMode(Mode::Searching)))
104                .unwrap();
105            tx.send(Action::Search(SearchAction::ClearSearchResults))
106                .unwrap();
107            match search_request.search().await {
108                Ok(search) => tx
109                    .send(Action::Search(SearchAction::FinshSearch(search)))
110                    .unwrap(),
111                Err(error) => {
112                    let error = error.context("Unable to execute the search");
113                    tx.send(Action::Search(SearchAction::ChangeMode(Mode::NoSearch)))
114                        .unwrap();
115                    tx.send(Action::PopupError(format!("{:?}", error))).unwrap();
116                    error!("{:?}", error);
117                }
118            };
119        });
120
121        ActionResult::consumed()
122    }
123
124    fn finish_search(&mut self, mut search: ApiSearch) -> ActionResult {
125        let has_results = search.info.total_hits.unwrap_or_default() != 0;
126        let has_suggestion = search.info.suggestion.is_some();
127
128        self.search_results
129            .get_items_mut()
130            .append(&mut search.results);
131
132        self.continue_search = search.continue_data();
133        self.search_info = Some(search.info);
134
135        crate::trace_dbg!(has_results);
136        crate::trace_dbg!(has_suggestion);
137
138        if !has_results && !has_suggestion {
139            warn!("could not find any results and no suggestion was given");
140            return ActionPacket::single(Action::PopupMessage(
141                "Warning".to_string(),
142                "Could not find any search results and no suggestion could be made".to_string(),
143            ))
144            .action(Action::Search(SearchAction::ChangeMode(Mode::NoSearch)))
145            .into();
146        }
147
148        if !has_results && has_suggestion {
149            info!("could not find any results, but a suggestion was given",);
150            return Action::Search(SearchAction::ChangeMode(Mode::Suggestion)).into();
151        }
152
153        Action::Search(SearchAction::ChangeMode(Mode::FinishedSearch)).into()
154    }
155
156    fn continue_search(&mut self) -> ActionResult {
157        if self.continue_search.is_none() {
158            return ActionPacket::single(Action::PopupMessage(
159                "Warning".to_string(),
160                "Could not find any search results and no suggestion could be made".to_string(),
161            ))
162            .action(Action::Search(SearchAction::ChangeMode(Mode::NoSearch)))
163            .into();
164        }
165
166        let code = self.continue_search.as_ref().unwrap();
167        let tx = self.action_tx.clone().unwrap();
168        let search_request = ApiSearch::builder()
169            .query(code.query.clone())
170            .endpoint(code.endpoint.clone())
171            .language(code.language)
172            .offset(code.offset);
173        tokio::spawn(async move {
174            tx.send(Action::Search(SearchAction::ChangeMode(Mode::Searching)))
175                .unwrap();
176            match search_request.search().await {
177                Ok(search) => tx
178                    .send(Action::Search(SearchAction::FinshSearch(search)))
179                    .unwrap(),
180                Err(error) => {
181                    let error = error.context("Unable to continue the search");
182                    tx.send(Action::Search(SearchAction::ChangeMode(Mode::NoSearch)))
183                        .unwrap();
184                    tx.send(Action::PopupError(error.to_string())).unwrap();
185                    error!("{:?}", error)
186                }
187            };
188        });
189
190        ActionResult::consumed()
191    }
192
193    fn open_selected_result(&self) -> ActionResult {
194        if let Some(selected_result) = self.search_results.selected() {
195            return ActionPacket::default()
196                .action(Action::ClearSearchBar)
197                .action(Action::LoadSearchResult(selected_result.clone()))
198                .into();
199        }
200        ActionResult::Ignored
201    }
202
203    fn clear_search_results(&mut self) -> ActionResult {
204        self.search_results = StatefulList::with_items(Vec::new());
205        self.continue_search = None;
206        self.search_info = None;
207
208        ActionResult::consumed()
209    }
210
211    fn change_mode(&mut self, mode: Mode) -> ActionResult {
212        self.mode = mode;
213        ActionResult::consumed()
214    }
215
216    fn change_language(&mut self, lang: Language) -> ActionResult {
217        self.endpoint = Some(
218            // HACK: we need a way to get the API endpoint from the language
219            Endpoint::parse(&format!("https://{}.wikipedia.org/w/api.php", lang.code())).unwrap(),
220        );
221        self.language = Some(lang);
222        ActionResult::consumed()
223    }
224}
225
226impl Component for SearchComponent {
227    fn init(
228        &mut self,
229        sender: mpsc::UnboundedSender<Action>,
230        config: Arc<Config>,
231        theme: Arc<Theme>,
232    ) -> anyhow::Result<()> {
233        self.action_tx = Some(sender);
234        self.config = config;
235        self.theme = theme;
236        Ok(())
237    }
238
239    fn handle_key_events(&mut self, key: KeyEvent) -> ActionResult {
240        match self.mode {
241            Mode::Searching => ActionResult::Ignored,
242            Mode::Suggestion => {
243                match key.code {
244                    KeyCode::Char('y') => {
245                        // HACK: well, some checks would be cool
246                        let suggestion = self
247                            .search_info
248                            .as_ref()
249                            .unwrap()
250                            .suggestion
251                            .as_ref()
252                            .unwrap()
253                            .as_str();
254                        Action::Search(SearchAction::StartSearch(suggestion.to_string())).into()
255                    }
256                    KeyCode::Char('n') => {
257                        Action::Search(SearchAction::ChangeMode(Mode::NoSearch)).into()
258                    }
259                    _ => ActionResult::Ignored,
260                }
261            }
262            Mode::FinishedSearch => match key.code {
263                _ if self.search_results.is_selected()
264                    && self.config.bindings.global.submit.matches_event(key) =>
265                {
266                    Action::Search(SearchAction::OpenSearchResult).into()
267                }
268                _ if self
269                    .config
270                    .bindings
271                    .search
272                    .continue_search
273                    .matches_event(key) =>
274                {
275                    Action::Search(SearchAction::ContinueSearch).into()
276                }
277                _ => ActionResult::Ignored,
278            },
279            _ => ActionResult::Ignored,
280        }
281    }
282
283    fn update(&mut self, action: Action) -> ActionResult {
284        match action {
285            Action::Search(search_action) => match search_action {
286                SearchAction::StartSearch(query) => self.start_search(query),
287                SearchAction::FinshSearch(search) => self.finish_search(search),
288                SearchAction::ContinueSearch => self.continue_search(),
289                SearchAction::ClearSearchResults => self.clear_search_results(),
290                SearchAction::OpenSearchResult => self.open_selected_result(),
291                SearchAction::ChangeMode(mode) => self.change_mode(mode),
292                SearchAction::ChangeLanguage(lang) => self.change_language(lang),
293            },
294
295            Action::ScrollUp(n) => {
296                for _ in 0..n {
297                    self.search_results.previous()
298                }
299                ActionResult::consumed()
300            }
301            Action::ScrollDown(n) => {
302                for _ in 0..n {
303                    self.search_results.next()
304                }
305                ActionResult::consumed()
306            }
307            Action::UnselectScroll => {
308                self.search_results.unselect();
309                ActionResult::consumed()
310            }
311            _ => ActionResult::Ignored,
312        }
313    }
314
315    fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
316        if self.mode == Mode::Searching {
317            f.render_widget(
318                self.theme
319                    .default_block()
320                    .border_style(Style::default().fg(Color::Yellow)),
321                area,
322            );
323            f.render_widget(
324                self.theme
325                    .default_paragraph("Searching. Please wait...")
326                    .alignment(Alignment::Center),
327                centered_rect(area, 100, 50),
328            );
329            return;
330        }
331
332        if self.mode == Mode::NoSearch {
333            f.render_widget(
334                self.theme
335                    .default_paragraph("Start a search!")
336                    .alignment(Alignment::Center),
337                centered_rect(area, 100, 50),
338            );
339            return;
340        }
341
342        if self.mode == Mode::Suggestion {
343            if self.search_info.is_none() {
344                return;
345            }
346
347            let block = self.theme.default_block().title("Information");
348            let msg = format!(
349                "No results for '{}' were found. Do you want to search for '{}' instead?\n\n[y]/[n]",
350                self.search_info.as_ref().unwrap().query.as_str(),
351                self.search_info
352                    .as_ref()
353                    .unwrap()
354                    .suggestion
355                    .as_ref()
356                    .unwrap()
357            );
358            let area = centered_rect(area, 60, 25);
359            f.render_widget(
360                self.theme
361                    .default_paragraph(msg)
362                    .block(block)
363                    .wrap(Wrap { trim: true }),
364                area,
365            );
366        }
367
368        // below this we should be in FinishedSearch mode
369        if self.mode != Mode::FinishedSearch {
370            return;
371        }
372
373        if self.search_results.get_items().is_empty() {
374            f.render_widget(
375                self.theme
376                    .default_paragraph("Start a search to view the results!")
377                    .alignment(Alignment::Center),
378                centered_rect(area, 100, 50),
379            );
380            return;
381        }
382
383        let [info_area, results_area] = {
384            let rects = Layout::default()
385                .direction(Direction::Vertical)
386                .constraints([Constraint::Percentage(100), Constraint::Min(1)])
387                .split(area);
388            [rects[1], rects[0]]
389        };
390
391        if let Some(ref search_info) = self.search_info {
392            let info = self
393                .theme
394                .default_paragraph(format!(
395                    " wiki-tui | Results: '{}' | Language: '{}' | [c]ontinue",
396                    search_info.total_hits.unwrap_or_default(),
397                    search_info.language.name()
398                ))
399                .style(
400                    Style::default()
401                        .fg(self.theme.status_bar_fg)
402                        .bg(self.theme.status_bar_bg),
403                );
404
405            f.render_widget(info, info_area);
406        }
407
408        // TODO: Somehow implement list item margin
409        let results_list_width = results_area.width.saturating_sub(3); // HACK: subtract 3 for
410                                                                       // border and highlight symbol
411        let items: Vec<ListItem> = self
412            .search_results
413            .get_items()
414            .iter()
415            .map(|result| {
416                let snippet = result.cleaned_snippet();
417                let mut text =
418                    Text::from(Span::raw(result.title.clone()).fg(self.theme.search_title_fg));
419                text.lines.append(
420                    &mut textwrap::wrap(&snippet, results_list_width as usize)
421                        .iter()
422                        .map(|s| {
423                            Line::from(s.to_string()).style(Style::default().fg(self.theme.fg))
424                        })
425                        .collect(),
426                );
427                ListItem::new(text)
428            })
429            .collect();
430
431        let items = List::new(items)
432            .block(self.theme.default_block().title("Results"))
433            .repeat_highlight_symbol(true)
434            .highlight_symbol("| ")
435            .highlight_spacing(HighlightSpacing::Always)
436            .highlight_style(
437                Style::default()
438                    .fg(self.theme.selected_fg)
439                    .bg(self.theme.selected_bg)
440                    .add_modifier(Modifier::ITALIC),
441            );
442        f.render_stateful_widget(items, results_area, self.search_results.get_state_mut());
443    }
444
445    fn handle_events(&mut self, event: Option<crate::event::Event>) -> ActionResult {
446        match event {
447            Some(crate::event::Event::Quit) => Action::Quit.into(),
448            Some(crate::event::Event::RenderTick) => Action::RenderTick.into(),
449            Some(crate::event::Event::Key(key_event)) => self.handle_key_events(key_event),
450            Some(crate::event::Event::Resize(x, y)) => Action::Resize(x, y).into(),
451            None => ActionResult::Ignored,
452        }
453    }
454}