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 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 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 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 let results_list_width = results_area.width.saturating_sub(3); 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}