diff --git a/Cargo.lock b/Cargo.lock index ca5c72a..b4c685e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,7 +516,7 @@ dependencies = [ "libc", "num-format", "pretty_assertions", - "ratatui 0.26.0", + "ratatui 0.26.1-alpha.1", "ratatui-macros", "serde", "serde_with", @@ -1821,9 +1821,9 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.26.0" +version = "0.26.1-alpha.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "154b85ef15a5d1719bcaa193c3c81fe645cd120c156874cd660fe49fd21d1373" +checksum = "5cfa59531a59983ab60e4433f1db8bf85751e0b6dfca30f7a3863b45049340dc" dependencies = [ "bitflags 2.4.2", "cassowary", diff --git a/Cargo.toml b/Cargo.toml index 9831f74..8f01946 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ lazy_static = "1.4.0" libc = "0.2.148" num-format = "0.4.4" pretty_assertions = "1.4.0" -ratatui = { version = "0.26.0", features = ["serde", "macros"] } +ratatui = { version = "0.26.1-alpha.0", features = ["serde", "macros"] } ratatui-macros = "0.2.3" serde = { version = "1.0.188", features = ["derive"] } serde_with = "3.5.0" diff --git a/src/action.rs b/src/action.rs index 9fdb553..24c9632 100644 --- a/src/action.rs +++ b/src/action.rs @@ -5,7 +5,6 @@ use crate::app::Mode; #[derive(Debug, Display, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Action { - Ignore, Tick, Render, KeyRefresh, @@ -24,7 +23,6 @@ pub enum Action { GetCrates, SwitchMode(Mode), SwitchToLastMode, - HandleFilterPromptChange, IncrementPage, DecrementPage, NextSummaryMode, diff --git a/src/app.rs b/src/app.rs index 8754f79..cfb9843 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,36 +1,29 @@ -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }, +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, }; use color_eyre::eyre::Result; use crossterm::event::KeyEvent; -use itertools::Itertools; use ratatui::{prelude::*, widgets::*}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumIs}; -use tokio::{ - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, - task::JoinHandle, -}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tracing::{debug, error, info}; -use tui_input::backend::crossterm::EventHandler; use crate::{ action::Action, - config, crates_io_api_helper, + config, events::{Event, Events}, serde_helper::keybindings::key_event_to_string, tui::Tui, widgets::{ - crate_info_table::{CrateInfo, CrateInfoTableWidget}, help::{Help, HelpWidget}, popup_message::{PopupMessageState, PopupMessageWidget}, - search_filter_prompt::{SearchFilterPrompt, SearchFilterPromptWidget}, - search_results_table::{SearchResultsTable, SearchResultsTableWidget}, + search_filter_prompt::SearchFilterPromptWidget, + search_page::SearchPage, + search_page::SearchPageWidget, + status_bar::StatusBarWidget, summary::{Summary, SummaryWidget}, tabs::SelectedTab, }, @@ -44,36 +37,23 @@ pub enum Mode { Common, #[default] Summary, - Search, - Filter, - // Picker(CrateInfo), unable to make configuration file work with this PickerShowCrateInfo, PickerHideCrateInfo, + Search, + Filter, Popup, Help, Quit, } impl Mode { - pub fn focused(&self) -> bool { - matches!(self, Mode::Search | Mode::Filter) + pub fn is_prompt(&self) -> bool { + self.is_search() || self.is_filter() } pub fn is_picker(&self) -> bool { self.is_picker_hide_crate_info() || self.is_picker_show_crate_info() } - - pub fn toggle_crate_info(&mut self) { - *self = match self { - Mode::PickerShowCrateInfo => Mode::PickerHideCrateInfo, - Mode::PickerHideCrateInfo => Mode::PickerShowCrateInfo, - _ => *self, - }; - } - - pub fn should_show_crate_info(&self) -> bool { - matches!(self, Mode::PickerShowCrateInfo) - } } struct AppWidget; @@ -88,73 +68,10 @@ pub struct App { /// various parts of the app to be handled by the event loop. tx: UnboundedSender, - /// The current page number being displayed or interacted with in the UI. - page: u64, - - /// The number of crates displayed per page in the UI. - page_size: u64, - - /// Sort preference for search results - sort: crates_io_api::Sort, - /// A thread-safe indicator of whether data is currently being loaded, /// allowing different parts of the app to know if it's in a loading state. loading_status: Arc, - /// A thread-safe, shared vector holding the list of crates fetched from - /// crates.io, wrapped in a mutex to control concurrent access. - crates: Arc>>, - - /// A thread-safe, shared vector holding the list of version fetched from - /// crates.io, wrapped in a mutex to control concurrent access. - versions: Arc>>, - - /// A thread-safe shared container holding the detailed information about - /// the currently selected crate; this can be `None` if no crate is - /// selected. - full_crate_info: Arc>>, - - /// A thread-safe shared container holding the detailed information about - /// the currently selected crate; this can be `None` if no crate is - /// selected. - crate_response: Arc>>, - - /// A thread-safe shared container holding the detailed information about - /// the currently selected crate; this can be `None` if no crate is - /// selected. - summary_data: Arc>>, - - /// contains list state for summary - summary: Summary, - - /// contains table state for info popup - crate_info: CrateInfo, - - last_task_details_handle: HashMap>, - - /// The total number of crates fetchable from crates.io, which may not be - /// known initially and can be used for UI elements like pagination. - total_num_crates: Option, - - /// A string for the current search input by the user, submitted to - /// crates.io as a query - search: String, - - /// A string for the current filter input by the user, used only locally - /// for filtering for the list of crates in the current view. - filter: String, - - /// An input handler component for managing raw user input into textual - /// form. - input: tui_input::Input, - - /// A table component designed to handle the listing and selection of crates - /// within the terminal UI. - search_results: SearchResultsTable, - - /// A popupt to show info / error messages - popup: Option<(PopupMessageWidget, PopupMessageState)>, - /// The active mode of the application, which could change how user inputs /// and commands are interpreted. mode: Mode, @@ -163,10 +80,6 @@ pub struct App { /// and commands are interpreted. last_mode: Mode, - /// A prompt displaying the current search or filter query, if any, that the - /// user can interact with. - prompt: SearchFilterPrompt, - /// A list of key events that have been held since the last tick, useful for /// interpreting sequences of key presses. last_tick_key_events: Vec, @@ -174,38 +87,28 @@ pub struct App { /// frame counter frame_count: usize, + summary: Summary, + search: SearchPage, + popup: Option<(PopupMessageWidget, PopupMessageState)>, help: Help, - selected_tab: SelectedTab, } impl App { pub fn new() -> Self { let (tx, rx) = mpsc::unbounded_channel(); + let loading_status = Arc::new(AtomicBool::default()); + let search = SearchPage::new(tx.clone(), loading_status.clone()); + let summary = Summary::new(tx.clone(), loading_status.clone()); Self { rx, tx, - page: 1, - page_size: 25, - sort: crates_io_api::Sort::Relevance, mode: Mode::default(), last_mode: Mode::default(), - loading_status: Default::default(), - search: Default::default(), - filter: Default::default(), - crates: Default::default(), - versions: Default::default(), - full_crate_info: Default::default(), - crate_response: Default::default(), - crate_info: Default::default(), - summary_data: Default::default(), - summary: Default::default(), - last_task_details_handle: Default::default(), - total_num_crates: Default::default(), - input: Default::default(), - search_results: Default::default(), + loading_status, + search, + summary, popup: Default::default(), - prompt: Default::default(), last_tick_key_events: Default::default(), frame_count: Default::default(), help: Default::default(), @@ -248,31 +151,25 @@ impl App { Event::KeyRefresh => Some(Action::KeyRefresh), Event::Render => Some(Action::Render), Event::Resize(x, y) => Some(Action::Resize(x, y)), - Event::Key(key) => { - debug!("Received key {:?}", key); - self.forward_key_events(key)?; - self.handle_key_events_from_config(key) - } + Event::Key(key) => self.handle_key_event(key)?, _ => None, }; Ok(maybe_action) } - /// Processes key events depending on the current mode - /// - /// This function forwards events to input prompt handler - fn forward_key_events(&mut self, key: KeyEvent) -> Result<()> { + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + debug!("Received key {:?}", key); match self.mode { Mode::Search => { - self.input.handle_event(&crossterm::event::Event::Key(key)); + self.search.handle_key(key); } Mode::Filter => { - self.input.handle_event(&crossterm::event::Event::Key(key)); - self.tx.send(Action::HandleFilterPromptChange)? + self.search.handle_key(key); + self.search.handle_filter_prompt_change(); } _ => (), }; - Ok(()) + Ok(self.handle_key_events_from_config(key)) } /// Evaluates a sequence of key events against user-configured key bindings @@ -319,40 +216,38 @@ impl App { Action::StoreTotalNumberOfCrates(n) => self.store_total_number_of_crates(n), Action::ScrollUp => self.scroll_up(), Action::ScrollDown => self.scroll_down(), - Action::ScrollTop => self.search_results.scroll_to_top(), - Action::ScrollBottom => self.search_results.scroll_to_bottom(), - Action::ScrollCrateInfoUp => self.crate_info.scroll_previous(), - Action::ScrollCrateInfoDown => self.crate_info.scroll_next(), - Action::ScrollSearchResultsUp => self.search_results.scroll_previous(1), - Action::ScrollSearchResultsDown => self.search_results.scroll_next(1), - Action::ReloadData => self.reload_data(), - Action::IncrementPage => self.increment_page(), - Action::DecrementPage => self.decrement_page(), + + Action::ScrollTop + | Action::ScrollBottom + | Action::ScrollSearchResultsUp + | Action::ScrollSearchResultsDown => self.search.handle_action(action.clone()), + + Action::ScrollCrateInfoUp => self.search.crate_info.scroll_previous(), + Action::ScrollCrateInfoDown => self.search.crate_info.scroll_next(), + Action::ReloadData => self.search.reload_data(), + Action::IncrementPage => self.search.increment_page(), + Action::DecrementPage => self.search.decrement_page(), Action::NextSummaryMode => self.summary.next_mode(), Action::PreviousSummaryMode => self.summary.previous_mode(), Action::NextTab => self.goto_next_tab(), Action::PreviousTab => self.goto_previous_tab(), - Action::SwitchMode(mode) if mode.is_search() || mode.is_filter() => { - self.enter_insert_mode(mode) - } - Action::SwitchMode(Mode::PickerHideCrateInfo) => self.enter_normal_mode(), - Action::SwitchMode(Mode::PickerShowCrateInfo) => self.enter_normal_mode(), Action::SwitchMode(mode) => self.switch_mode(mode), Action::SwitchToLastMode => self.switch_to_last_mode(), - Action::HandleFilterPromptChange => self.handle_filter_prompt_change(), - Action::SubmitSearch => self.submit_search(), - Action::ToggleShowCrateInfo => self.toggle_show_crate_info(), + Action::SubmitSearch => self.search.submit_query(), + Action::ToggleShowCrateInfo => self.search.toggle_show_crate_info(), Action::UpdateCurrentSelectionCrateInfo => self.update_current_selection_crate_info(), - Action::UpdateSearchTableResults => self.update_search_table_results(), - Action::UpdateSummary => self.update_summary(), + Action::UpdateSearchTableResults => self.search.update_search_table_results(), + Action::UpdateSummary => self.summary.update(), Action::ShowFullCrateInfo => self.show_full_crate_details(), Action::ShowErrorPopup(ref err) => self.show_error_popup(err.clone()), Action::ShowInfoPopup(ref info) => self.show_info_popup(info.clone()), Action::ClosePopup => self.close_popup(), - Action::ToggleSortBy { reload, forward } => self.toggle_sort_by(reload, forward)?, - Action::ClearTaskDetailsHandle(ref id) => { - self.clear_task_details_handle(uuid::Uuid::parse_str(id)?)? + Action::ToggleSortBy { reload, forward } => { + self.search.toggle_sort_by(reload, forward)? } + Action::ClearTaskDetailsHandle(ref id) => self + .search + .clear_task_details_handle(uuid::Uuid::parse_str(id)?)?, Action::OpenDocsUrlInBrowser => self.open_docs_url_in_browser()?, Action::OpenCratesIOUrlInBrowser if self.mode.is_summary() => { self.open_summary_url_in_browser()? @@ -389,49 +284,14 @@ impl App { impl App { fn tick(&mut self) { - self.update_search_table_results(); + self.search.update_search_table_results(); } fn init(&mut self) -> Result<()> { - self.request_summary()?; + self.summary.request()?; Ok(()) } - fn update_summary(&mut self) { - if let Some(summary) = self.summary_data.lock().unwrap().clone() { - self.summary.summary_data = Some(summary); - } else { - self.summary.summary_data = None; - } - } - - fn update_search_table_results(&mut self) { - self.search_results - .content_length(self.search_results.crates.len()); - - let filter = self.filter.clone(); - let filter_words = filter.split_whitespace().collect::>(); - - let crates: Vec<_> = self - .crates - .lock() - .unwrap() - .iter() - .filter(|c| { - filter_words.iter().all(|word| { - c.name.to_lowercase().contains(word) - || c.description - .clone() - .unwrap_or_default() - .to_lowercase() - .contains(word) - }) - }) - .cloned() - .collect_vec(); - self.search_results.crates = crates; - } - fn key_refresh_tick(&mut self) { self.last_tick_key_events.drain(..); } @@ -459,7 +319,7 @@ impl App { } Mode::Summary => self.summary.scroll_previous(), Mode::Help => self.help.scroll_previous(), - _ => self.search_results.scroll_previous(1), + _ => self.search.scroll_up(), } } @@ -472,59 +332,40 @@ impl App { } Mode::Summary => self.summary.scroll_next(), Mode::Help => self.help.scroll_next(), - _ => self.search_results.scroll_next(1), - } - } - - fn increment_page(&mut self) { - if let Some(n) = self.total_num_crates { - let max_page_size = (n / self.page_size) + 1; - if self.page < max_page_size { - self.page = self.page.saturating_add(1).min(max_page_size); - self.reload_data(); - } - } - } - - fn decrement_page(&mut self) { - let min_page_size = 1; - if self.page > min_page_size { - self.page = self.page.saturating_sub(1).max(min_page_size); - self.reload_data(); - } - } - - fn enter_insert_mode(&mut self, mode: Mode) { - self.switch_mode(mode); - self.input = self.input.clone().with_value(if self.mode.is_search() { - self.search.clone() - } else if self.mode.is_filter() { - self.filter.clone() - } else { - unreachable!("Cannot enter insert mode when mode is {:?}", self.mode) - }); - } - - fn enter_normal_mode(&mut self) { - self.switch_mode(Mode::PickerHideCrateInfo); - if !self.search_results.crates.is_empty() && self.search_results.selected().is_none() { - self.search_results.select(Some(0)) + _ => self.search.scroll_down(), } } fn switch_mode(&mut self, mode: Mode) { self.last_mode = self.mode; self.mode = mode; + self.search.mode = mode; match self.mode { - Mode::Search | Mode::Filter | Mode::PickerHideCrateInfo | Mode::PickerShowCrateInfo => { - self.selected_tab.select(SelectedTab::Search) + Mode::Search => { + self.selected_tab.select(SelectedTab::Search); + self.search.enter_search_insert_mode(); + } + Mode::Filter => { + self.selected_tab.select(SelectedTab::Search); + self.search.enter_filter_insert_mode(); + } + Mode::Summary => { + self.search.enter_normal_mode(); + self.selected_tab.select(SelectedTab::Summary); } - Mode::Summary => self.selected_tab.select(SelectedTab::Summary), Mode::Help => { + self.search.enter_normal_mode(); self.help.mode = Some(self.last_mode); self.selected_tab.select(SelectedTab::None) } - _ => self.selected_tab.select(SelectedTab::None), + Mode::PickerShowCrateInfo | Mode::PickerHideCrateInfo => { + self.search.enter_normal_mode(); + self.selected_tab.select(SelectedTab::Search) + } + _ => { + self.search.enter_normal_mode(); + self.selected_tab.select(SelectedTab::None) + } } } @@ -548,71 +389,13 @@ impl App { } } - fn handle_filter_prompt_change(&mut self) { - self.filter = self.input.value().into(); - self.search_results.select(None); - } - - fn submit_search(&mut self) { - self.clear_all_previous_task_details_handles(); - self.switch_mode(Mode::PickerHideCrateInfo); - self.filter.clear(); - self.search = self.input.value().into(); - } - - fn toggle_show_crate_info(&mut self) { - self.mode.toggle_crate_info(); - if self.mode.should_show_crate_info() { - self.request_crate_details() - } else { - self.clear_all_previous_task_details_handles(); - } - } - - fn toggle_sort_by_forward(&mut self) { - use crates_io_api::Sort as S; - self.sort = match self.sort { - S::Alphabetical => S::Relevance, - S::Relevance => S::Downloads, - S::Downloads => S::RecentDownloads, - S::RecentDownloads => S::RecentUpdates, - S::RecentUpdates => S::NewlyAdded, - S::NewlyAdded => S::Alphabetical, - }; - } - - fn toggle_sort_by_backward(&mut self) { - use crates_io_api::Sort as S; - self.sort = match self.sort { - S::Relevance => S::Alphabetical, - S::Downloads => S::Relevance, - S::RecentDownloads => S::Downloads, - S::RecentUpdates => S::RecentDownloads, - S::NewlyAdded => S::RecentUpdates, - S::Alphabetical => S::NewlyAdded, - }; - } - - fn toggle_sort_by(&mut self, reload: bool, forward: bool) -> Result<()> { - if forward { - self.toggle_sort_by_forward() - } else { - self.toggle_sort_by_backward() - }; - if reload { - self.tx.send(Action::ReloadData)?; - } - Ok(()) - } - fn show_error_popup(&mut self, message: String) { error!("Error: {message}"); self.popup = Some(( PopupMessageWidget::new("Error".into(), message), PopupMessageState::default(), )); - self.last_mode = self.mode; - self.mode = Mode::Popup; + self.switch_mode(Mode::Popup); } fn show_info_popup(&mut self, info: String) { @@ -621,36 +404,34 @@ impl App { PopupMessageWidget::new("Info".into(), info), PopupMessageState::default(), )); - self.last_mode = self.mode; - self.mode = Mode::Popup; + self.switch_mode(Mode::Popup); } fn close_popup(&mut self) { self.popup = None; - self.mode = if self.last_mode.is_popup() { - Mode::Search + if self.last_mode.is_popup() { + self.switch_mode(Mode::Search); } else { - self.last_mode + self.switch_mode(self.last_mode); } } fn update_current_selection_crate_info(&mut self) { - self.clear_all_previous_task_details_handles(); - self.request_crate_details(); + self.search.clear_all_previous_task_details_handles(); + self.search.request_crate_details(); } fn show_full_crate_details(&mut self) { - self.clear_all_previous_task_details_handles(); - self.request_full_crate_details(); - // self.mode = Mode::FullCrateDetails; + self.search.clear_all_previous_task_details_handles(); + self.search.request_full_crate_details(); } fn store_total_number_of_crates(&mut self, n: u64) { - self.total_num_crates = Some(n) + self.search.total_num_crates = Some(n) } fn open_docs_url_in_browser(&self) -> Result<()> { - if let Some(crate_response) = self.crate_response.lock().unwrap().clone() { + if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { let name = crate_response.crate_data.name; webbrowser::open(&format!("https://docs.rs/{name}/latest"))?; } @@ -669,7 +450,7 @@ impl App { } fn open_crates_io_url_in_browser(&self) -> Result<()> { - if let Some(crate_response) = self.crate_response.lock().unwrap().clone() { + if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { let name = crate_response.crate_data.name; webbrowser::open(&format!("https://crates.io/crates/{name}"))?; } @@ -680,7 +461,7 @@ impl App { use copypasta::ClipboardProvider; match copypasta::ClipboardContext::new() { Ok(mut ctx) => { - if let Some(crate_response) = self.crate_response.lock().unwrap().clone() { + if let Some(crate_response) = self.search.crate_response.lock().unwrap().clone() { let msg = format!("cargo add {}", crate_response.crate_data.name); let _ = match ctx.set_contents(msg.clone()).ok() { Some(_) => self.tx.send(Action::ShowInfoPopup(format!( @@ -706,143 +487,6 @@ impl App { Ok(()) } - fn clear_task_details_handle(&mut self, id: uuid::Uuid) -> Result<()> { - if let Some((_, handle)) = self.last_task_details_handle.remove_entry(&id) { - handle.abort() - } - Ok(()) - } - - fn clear_all_previous_task_details_handles(&mut self) { - *self.full_crate_info.lock().unwrap() = None; - for (_, v) in self.last_task_details_handle.iter() { - v.abort() - } - self.last_task_details_handle.clear() - } - - /// Reloads the list of crates based on the current search parameters, - /// updating the application state accordingly. This involves fetching - /// data asynchronously from the crates.io API and updating various parts of - /// the application state, such as the crates listing, current crate - /// info, and loading status. - fn reload_data(&mut self) { - self.prepare_reload(); - let search_params = self.create_search_parameters(); - self.request_search_results(search_params); - } - - /// Clears current search results and resets the UI to prepare for new data. - fn prepare_reload(&mut self) { - self.search_results.select(None); - *self.full_crate_info.lock().unwrap() = None; - *self.crate_response.lock().unwrap() = None; - } - - /// Creates the parameters required for the search task. - fn create_search_parameters(&self) -> crates_io_api_helper::SearchParameters { - crates_io_api_helper::SearchParameters { - search: self.search.clone(), - page: self.page.clamp(1, u64::MAX), - page_size: self.page_size, - crates: self.crates.clone(), - versions: self.versions.clone(), - loading_status: self.loading_status.clone(), - sort: self.sort.clone(), - tx: self.tx.clone(), - } - } - - /// Spawns an asynchronous task to fetch crate data from crates.io. - fn request_search_results(&self, params: crates_io_api_helper::SearchParameters) { - tokio::spawn(async move { - params.loading_status.store(true, Ordering::SeqCst); - if let Err(error_message) = crates_io_api_helper::request_search_results(¶ms).await - { - let _ = params.tx.send(Action::ShowErrorPopup(error_message)); - } - let _ = params.tx.send(Action::UpdateSearchTableResults); - params.loading_status.store(false, Ordering::SeqCst); - }); - } - - /// Spawns an asynchronous task to fetch crate details from crates.io based - /// on currently selected crate - fn request_crate_details(&mut self) { - if self.search_results.crates.is_empty() { - return; - } - if let Some(crate_name) = self.search_results.selected_crate_name() { - let tx = self.tx.clone(); - let crate_response = self.crate_response.clone(); - let loading_status = self.loading_status.clone(); - - // Spawn the async work to fetch crate details. - let uuid = uuid::Uuid::new_v4(); - let last_task_details_handle = tokio::spawn(async move { - info!("Requesting details for {crate_name}: {uuid}"); - loading_status.store(true, Ordering::SeqCst); - if let Err(error_message) = - crates_io_api_helper::request_crate_details(&crate_name, crate_response).await - { - let _ = tx.send(Action::ShowErrorPopup(error_message)); - }; - loading_status.store(false, Ordering::SeqCst); - info!("Retrieved details for {crate_name}: {uuid}"); - let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); - }); - self.last_task_details_handle - .insert(uuid, last_task_details_handle); - } - } - - /// Spawns an asynchronous task to fetch crate details from crates.io based - /// on currently selected crate - fn request_full_crate_details(&mut self) { - if self.search_results.crates.is_empty() { - return; - } - if let Some(crate_name) = self.search_results.selected_crate_name() { - let tx = self.tx.clone(); - let full_crate_info = self.full_crate_info.clone(); - let loading_status = self.loading_status.clone(); - - // Spawn the async work to fetch crate details. - let uuid = uuid::Uuid::new_v4(); - let last_task_details_handle = tokio::spawn(async move { - info!("Requesting details for {crate_name}: {uuid}"); - loading_status.store(true, Ordering::SeqCst); - if let Err(error_message) = - crates_io_api_helper::request_full_crate_details(&crate_name, full_crate_info) - .await - { - let _ = tx.send(Action::ShowErrorPopup(error_message)); - }; - loading_status.store(false, Ordering::SeqCst); - info!("Retrieved details for {crate_name}: {uuid}"); - let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); - }); - self.last_task_details_handle - .insert(uuid, last_task_details_handle); - } - } - - fn request_summary(&self) -> Result<()> { - let tx = self.tx.clone(); - let loading_status = self.loading_status.clone(); - let summary = self.summary_data.clone(); - tokio::spawn(async move { - loading_status.store(true, Ordering::SeqCst); - if let Err(error_message) = crates_io_api_helper::request_summary(summary).await { - let _ = tx.send(Action::ShowErrorPopup(error_message)); - } - loading_status.store(false, Ordering::SeqCst); - let _ = tx.send(Action::UpdateSummary); - let _ = tx.send(Action::ScrollDown); - }); - Ok(()) - } - // Sets the frame count fn update_frame_count(&mut self, frame: &mut Frame<'_>) { self.frame_count = frame.count(); @@ -850,15 +494,10 @@ impl App { // Sets cursor for the prompt fn update_cursor(&mut self, frame: &mut Frame<'_>) { - if let Some(cursor_position) = self.prompt.cursor_position() { - frame.set_cursor(cursor_position.x, cursor_position.y) - } - } - - fn render_crate_info(&mut self, area: Rect, buf: &mut Buffer) { - if let Some(ci) = self.crate_response.lock().unwrap().clone() { - Clear.render(area, buf); - CrateInfoTableWidget::new(ci).render(area, buf, &mut self.crate_info); + if self.mode.is_prompt() { + if let Some(cursor_position) = self.search.cursor_position() { + frame.set_cursor(cursor_position.x, cursor_position.y) + } } } @@ -882,68 +521,59 @@ impl App { ) } - fn selected_with_page_context(&self) -> u64 { - self.search_results.selected().map_or(0, |n| { - (self.page.saturating_sub(1) * self.page_size) + n as u64 + 1 - }) - } - fn loading(&self) -> bool { self.loading_status.load(Ordering::SeqCst) } +} - fn page_number_status(&self) -> String { - let max_page_size = (self.total_num_crates.unwrap_or_default() / self.page_size) + 1; - format!("Page: {}/{}", self.page, max_page_size) - } +impl StatefulWidget for AppWidget { + type State = App; - fn search_results_status(&self) -> String { - let selected = self.selected_with_page_context(); - let ncrates = self.total_num_crates.unwrap_or_default(); - format!("{}/{} Results", selected, ncrates) - } + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + // Background color + Block::default() + .bg(config::get().color.base00) + .render(area, buf); - fn spinner(&self) -> String { - let spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let index = self.frame_count % spinner.len(); - let symbol = spinner[index]; - symbol.into() - } + use Constraint::*; + let [header, main] = Layout::vertical([Length(1), Fill(1)]).areas(area); + let [tabs, events] = Layout::horizontal([Min(15), Fill(1)]).areas(header); - fn render_search_results(&mut self, area: Rect, buf: &mut Buffer) { - SearchResultsTableWidget::new(self.mode.is_picker()).render( - area, - buf, - &mut self.search_results, - ); + state.render_tabs(tabs, buf); + state.events_widget().render(events, buf); - Line::from(self.page_number_status()).left_aligned().render( - area.inner(&Margin { - horizontal: 1, - vertical: 2, - }), - buf, - ); + let mode = if matches!(state.mode, Mode::Popup | Mode::Quit) { + state.last_mode + } else { + state.mode + }; + match mode { + Mode::Summary => state.render_summary(main, buf), + Mode::Help => state.render_help(main, buf), - Line::from(self.search_results_status()) - .right_aligned() - .render( - area.inner(&Margin { - horizontal: 1, - vertical: 2, - }), - buf, - ); - } + Mode::Search => state.render_search(main, buf), + Mode::Filter => state.render_search(main, buf), + Mode::PickerShowCrateInfo => state.render_search(main, buf), + Mode::PickerHideCrateInfo => state.render_search(main, buf), - fn render_summary(&mut self, area: Rect, buf: &mut Buffer) { - SummaryWidget.render(area, buf, &mut self.summary); - } + Mode::Common => {} + Mode::Popup => {} + Mode::Quit => {} + }; - fn render_help(&mut self, area: Rect, buf: &mut Buffer) { - HelpWidget.render(area, buf, &mut self.help) + if state.loading() { + Line::from(state.spinner()) + .right_aligned() + .render(main, buf); + } + + if let Some((popup, popup_state)) = &mut state.popup { + popup.render(area, buf, popup_state); + } } +} +impl App { fn render_tabs(&self, area: Rect, buf: &mut Buffer) { use strum::IntoEnumIterator; let titles = SelectedTab::iter().map(|tab| tab.title()); @@ -958,68 +588,62 @@ impl App { .render(area, buf); } - fn render_main(&mut self, area: Rect, buf: &mut Buffer, mode: Mode) { - match mode { - Mode::Summary => self.render_summary(area, buf), - Mode::Help => self.render_help(area, buf), - Mode::PickerShowCrateInfo => { - let [area, info] = - Layout::vertical([Constraint::Min(0), Constraint::Max(15)]).areas(area); - self.render_search_results(area, buf); - self.render_crate_info(info, buf); - } - Mode::PickerHideCrateInfo => self.render_search_results(area, buf), - Mode::Common => self.render_search_results(area, buf), - Mode::Search => self.render_search_results(area, buf), - Mode::Filter => self.render_search_results(area, buf), - Mode::Popup => self.render_main(area, buf, self.last_mode), - Mode::Quit => self.render_main(area, buf, self.last_mode), - } + fn render_summary(&mut self, area: Rect, buf: &mut Buffer) { + let [main, status_bar] = + Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).areas(area); + SummaryWidget.render(main, buf, &mut self.summary); + self.render_status_bar(status_bar, buf); } -} - -impl StatefulWidget for AppWidget { - type State = App; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - Block::default() - .bg(config::get().color.base00) - .render(area, buf); + fn render_help(&mut self, area: Rect, buf: &mut Buffer) { + let [main, status_bar] = + Layout::vertical([Constraint::Fill(0), Constraint::Length(1)]).areas(area); + HelpWidget.render(main, buf, &mut self.help); + self.render_status_bar(status_bar, buf); + } - let [tabs, table, prompt] = if state.mode.focused() { - Layout::vertical([ - Constraint::Length(1), - Constraint::Fill(0), - Constraint::Length(5), - ]) - .areas(area) + fn render_search(&mut self, area: Rect, buf: &mut Buffer) { + let prompt_height = if self.mode.is_prompt() && self.search.is_prompt() { + 5 } else { - Layout::vertical([ - Constraint::Length(1), - Constraint::Fill(0), - Constraint::Length(1), - ]) - .areas(area) + 0 }; + let [main, prompt, status_bar] = Layout::vertical([ + Constraint::Min(0), + Constraint::Length(prompt_height), + Constraint::Length(1), + ]) + .areas(area); - let [tabs, events] = - Layout::horizontal([Constraint::Min(15), Constraint::Fill(1)]).areas(tabs); - state.render_tabs(tabs, buf); - state.events_widget().render(events, buf); + SearchPageWidget::new(self.mode).render(main, buf, &mut self.search); - let p = SearchFilterPromptWidget::new(state.mode, state.sort.clone(), &state.input); - p.render(prompt, buf, &mut state.prompt); + self.render_prompt(prompt, buf); + self.render_status_bar(status_bar, buf); + } - state.render_main(table, buf, state.mode); + fn render_prompt(&mut self, area: Rect, buf: &mut Buffer) { + let p = SearchFilterPromptWidget::new( + self.mode, + self.search.sort.clone(), + &self.search.input, + self.search.search_mode, + ); + p.render(area, buf, &mut self.search.prompt); + } - if state.loading() { - Line::from(state.spinner()) - .right_aligned() - .render(table, buf); - } + fn render_status_bar(&mut self, area: Rect, buf: &mut Buffer) { + let s = StatusBarWidget::new( + self.mode, + self.search.sort.clone(), + self.search.input.value().to_string(), + ); + s.render(area, buf); + } - if let Some((popup, popup_state)) = &mut state.popup { - popup.render(area, buf, popup_state); - } + fn spinner(&self) -> String { + let spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + let index = self.frame_count % spinner.len(); + let symbol = spinner[index]; + symbol.into() } } diff --git a/src/command.rs b/src/command.rs index 02465a0..f2f077d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -5,7 +5,6 @@ use crate::app::Mode; #[derive(Debug, Display, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Command { - Ignore, Quit, NextTab, PreviousTab, diff --git a/src/serde_helper.rs b/src/serde_helper.rs index 0db1eb6..12791c2 100644 --- a/src/serde_helper.rs +++ b/src/serde_helper.rs @@ -15,7 +15,6 @@ pub mod keybindings { impl KeyBindings { pub fn command_to_action(&self, command: Command) -> Action { match command { - Command::Ignore => Action::Ignore, Command::Quit => Action::Quit, Command::NextTab => Action::NextTab, Command::PreviousTab => Action::PreviousTab, diff --git a/src/widgets.rs b/src/widgets.rs index 1059a5f..42098a8 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -2,6 +2,8 @@ pub mod crate_info_table; pub mod help; pub mod popup_message; pub mod search_filter_prompt; -pub mod search_results_table; +pub mod search_page; +pub mod search_results; +pub mod status_bar; pub mod summary; pub mod tabs; diff --git a/src/widgets/search_filter_prompt.rs b/src/widgets/search_filter_prompt.rs index 80bff36..4ef7e5a 100644 --- a/src/widgets/search_filter_prompt.rs +++ b/src/widgets/search_filter_prompt.rs @@ -1,6 +1,8 @@ -use ratatui::{layout::Position, prelude::*, widgets::*}; +use ratatui::{layout::Constraint::*, layout::Position, prelude::*, widgets::*}; -use crate::{app::Mode, command::Command, config}; +use crate::{app::Mode, config}; + +use super::search_page::SearchMode; #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)] pub struct SearchFilterPrompt { @@ -19,149 +21,61 @@ pub struct SearchFilterPromptWidget<'a> { input: &'a tui_input::Input, vertical_margin: u16, horizontal_margin: u16, + search_mode: SearchMode, } impl<'a> SearchFilterPromptWidget<'a> { - pub fn new(mode: Mode, sort: crates_io_api::Sort, input: &'a tui_input::Input) -> Self { + pub fn new( + mode: Mode, + sort: crates_io_api::Sort, + input: &'a tui_input::Input, + search_mode: SearchMode, + ) -> Self { Self { mode, sort, input, vertical_margin: 2, horizontal_margin: 2, + search_mode, } } +} - fn horizontal_margin(&self) -> u16 { - if self.mode.focused() { - self.horizontal_margin - } else { - 0 - } - } +impl StatefulWidget for SearchFilterPromptWidget<'_> { + type State = SearchFilterPrompt; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let [input, meta] = Layout::horizontal([Percentage(75), Fill(0)]).areas(area); - fn vertical_margin(&self) -> u16 { - if self.mode.focused() { - self.vertical_margin - } else { - 0 + self.input_block().render(area, buf); + + if self.search_mode.is_focused() { + self.sort_by_info().render(meta.inner(&self.margin()), buf); } + self.input_text(input.width as usize) + .render(input.inner(&self.margin()), buf); + + self.update_cursor_state(area, state); } +} +impl SearchFilterPromptWidget<'_> { fn input_block(&self) -> Block { - let line = if self.mode.is_filter() { - vec!["Filter: ".into(), "Enter".bold(), " to submit".into()] - } else if self.mode.is_search() { - vec!["Search: ".into(), "Enter".bold(), " to submit".into()] - } else if self.mode.is_summary() { - let help = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) - .into_iter() - .next() - .unwrap_or_default(); - let open_in_browser = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::OpenCratesIOUrlInBrowser) - .into_iter() - .next() - .unwrap_or_default(); - let search = config::get() - .key_bindings - .get_config_for_command(Mode::Common, Command::NextTab) - .into_iter() - .next() - .unwrap_or_default(); - vec![ - open_in_browser.bold(), - " to open in browser, ".into(), - search.bold(), - " to enter search, ".into(), - help.bold(), - " for help".into(), - ] - } else if self.mode.is_help() { - vec!["ESC".bold(), " to return".into()] + let borders = if self.search_mode.is_focused() { + Borders::ALL } else { - let search = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::SwitchMode(Mode::Search)) - .into_iter() - .next() - .unwrap_or_default(); - let filter = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::SwitchMode(Mode::Filter)) - .into_iter() - .next() - .unwrap_or_default(); - let help = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) - .into_iter() - .next() - .unwrap_or_default(); - vec![ - search.bold(), - " to search, ".into(), - filter.bold(), - " to filter, ".into(), - help.bold(), - " for help".into(), - ] + Borders::NONE + }; + let border_color = match self.mode { + Mode::Search => config::get().color.base0a, + Mode::Filter => config::get().color.base0b, + _ => config::get().color.base06, }; let input_block = Block::default() - .borders(if self.mode.focused() { - Borders::ALL - } else { - Borders::NONE - }) - .title( - block::Title::from(Line::from(line)).alignment(if self.mode.focused() { - Alignment::Left - } else { - Alignment::Right - }), - ) + .borders(borders) .fg(config::get().color.base05) - .border_style(match self.mode { - Mode::Search => Style::default().fg(config::get().color.base0a), - Mode::Filter => Style::default().fg(config::get().color.base0b), - _ => Style::default().fg(config::get().color.base06), - }); - if self.mode.is_search() { - let help = config::get() - .key_bindings - .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) - .into_iter() - .next() - .unwrap_or_default(); - let toggle_sort = config::get() - .key_bindings - .get_config_for_command( - Mode::Search, - Command::ToggleSortBy { - reload: false, - forward: true, - }, - ) - .into_iter() - .next() - .unwrap_or_default(); - input_block - .title(Line::from(vec![ - toggle_sort.bold(), - " to toggle sort".into(), - ])) - .title_alignment(Alignment::Right) - .title( - block::Title::from(Line::from(vec![help.bold(), " for help".into()])) - .position(block::Position::Bottom) - .alignment(Alignment::Right), - ) - } else { - input_block - } + .border_style(border_color); + input_block } fn sort_by_info(&self) -> impl Widget { @@ -174,7 +88,7 @@ impl<'a> SearchFilterPromptWidget<'a> { fn input_text(&self, width: usize) -> impl Widget + '_ { let scroll = self.input.cursor().saturating_sub(width.saturating_sub(4)); - let text = if self.mode.focused() { + let text = if self.search_mode.is_focused() { Line::from(vec![self.input.value().into()]) } else if self.mode.is_summary() || self.mode.is_help() { Line::from(vec![]) @@ -191,41 +105,22 @@ impl<'a> SearchFilterPromptWidget<'a> { fn update_cursor_state(&self, area: Rect, state: &mut SearchFilterPrompt) { let width = ((area.width as f64 * 0.75) as u16).saturating_sub(2); - if self.mode.focused() { + if self.search_mode.is_focused() { + let margin = self.margin(); state.cursor_position = Some(Position::new( - (area.x + self.horizontal_margin() + self.input.cursor() as u16).min(width), - area.y + self.vertical_margin(), + (area.x + margin.horizontal + self.input.cursor() as u16).min(width), + area.y + margin.vertical, )); } else { state.cursor_position = None } } -} -impl StatefulWidget for SearchFilterPromptWidget<'_> { - type State = SearchFilterPrompt; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - self.input_block().render(area, buf); - let [input, meta] = - Layout::horizontal([Constraint::Percentage(75), Constraint::Fill(0)]).areas(area); - - if self.mode.focused() { - self.sort_by_info().render( - meta.inner(&Margin { - horizontal: self.horizontal_margin(), - vertical: self.vertical_margin(), - }), - buf, - ); + fn margin(&self) -> Margin { + if self.search_mode.is_focused() { + Margin::new(self.horizontal_margin, self.vertical_margin) + } else { + Margin::default() } - self.input_text(input.width as usize).render( - input.inner(&Margin { - horizontal: self.horizontal_margin(), - vertical: self.vertical_margin(), - }), - buf, - ); - - self.update_cursor_state(area, state); } } diff --git a/src/widgets/search_page.rs b/src/widgets/search_page.rs new file mode 100644 index 0000000..5c260d4 --- /dev/null +++ b/src/widgets/search_page.rs @@ -0,0 +1,500 @@ +use color_eyre::Result; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, +}; +use strum::EnumIs; +use tracing::info; + +use crossterm::event::{Event as CrosstermEvent, KeyEvent}; +use itertools::Itertools; +use ratatui::prelude::*; +use ratatui::{layout::Position, widgets::StatefulWidget}; +use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle}; +use tui_input::{backend::crossterm::EventHandler, Input}; + +use crate::{ + action::Action, + app::Mode, + crates_io_api_helper, + widgets::{search_filter_prompt::SearchFilterPrompt, search_results::SearchResults}, +}; + +use super::{ + crate_info_table::{CrateInfo, CrateInfoTableWidget}, + search_results::SearchResultsWidget, +}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, EnumIs)] +pub enum SearchMode { + #[default] + Search, + Filter, + ResultsHideCrate, + ResultsShowCrate, +} + +impl SearchMode { + pub fn is_focused(&self) -> bool { + matches!(self, SearchMode::Search | SearchMode::Filter) + } + + pub fn toggle_show_crate_info(&mut self) { + *self = match self { + SearchMode::ResultsShowCrate => SearchMode::ResultsHideCrate, + SearchMode::ResultsHideCrate => SearchMode::ResultsShowCrate, + _ => *self, + }; + } + + pub fn should_show_crate_info(&self) -> bool { + matches!(self, SearchMode::ResultsShowCrate) + } +} + +#[derive(Debug)] +pub struct SearchPage { + pub mode: Mode, + pub search_mode: SearchMode, + pub crate_info: CrateInfo, + + /// A string for the current search input by the user, submitted to + /// crates.io as a query + pub search: String, + + /// A string for the current filter input by the user, used only locally + /// for filtering for the list of crates in the current view. + pub filter: String, + + /// A table component designed to handle the listing and selection of crates + /// within the terminal UI. + pub results: SearchResults, + + /// An input handler component for managing raw user input into textual + /// form. + pub input: tui_input::Input, + + /// A prompt displaying the current search or filter query, if any, that the + /// user can interact with. + pub prompt: SearchFilterPrompt, + + /// The current page number being displayed or interacted with in the UI. + pub page: u64, + + /// The number of crates displayed per page in the UI. + pub page_size: u64, + + /// Sort preference for search results + pub sort: crates_io_api::Sort, + + /// The total number of crates fetchable from crates.io, which may not be + /// known initially and can be used for UI elements like pagination. + pub total_num_crates: Option, + + /// A thread-safe, shared vector holding the list of crates fetched from + /// crates.io, wrapped in a mutex to control concurrent access. + pub crates: Arc>>, + + /// A thread-safe, shared vector holding the list of version fetched from + /// crates.io, wrapped in a mutex to control concurrent access. + pub versions: Arc>>, + + /// A thread-safe shared container holding the detailed information about + /// the currently selected crate; this can be `None` if no crate is + /// selected. + pub full_crate_info: Arc>>, + + /// A thread-safe shared container holding the detailed information about + /// the currently selected crate; this can be `None` if no crate is + /// selected. + pub crate_response: Arc>>, + + pub last_task_details_handle: HashMap>, + + /// Sender end of an asynchronous channel for dispatching actions from + /// various parts of the app to be handled by the event loop. + tx: UnboundedSender, + + /// A thread-safe indicator of whether data is currently being loaded, + /// allowing different parts of the app to know if it's in a loading state. + loading_status: Arc, +} + +impl SearchPage { + pub fn new(tx: UnboundedSender, loading_status: Arc) -> Self { + Self { + mode: Default::default(), + search_mode: Default::default(), + search: String::new(), + filter: String::new(), + results: SearchResults::default(), + input: Input::default(), + prompt: SearchFilterPrompt::default(), + page: 1, + page_size: 25, + sort: crates_io_api::Sort::Relevance, + total_num_crates: None, + crates: Default::default(), + versions: Default::default(), + full_crate_info: Default::default(), + crate_info: Default::default(), + crate_response: Default::default(), + last_task_details_handle: Default::default(), + tx, + loading_status, + } + } + + pub fn handle_action(&mut self, action: Action) { + match action { + Action::ScrollTop => self.results.scroll_to_top(), + Action::ScrollBottom => self.results.scroll_to_bottom(), + Action::ScrollSearchResultsUp => self.scroll_up(), + Action::ScrollSearchResultsDown => self.scroll_down(), + _ => {} + } + } + + pub fn update_search_table_results(&mut self) { + self.results.content_length(self.results.crates.len()); + + let filter = self.filter.clone(); + let filter_words = filter.split_whitespace().collect::>(); + + let crates: Vec<_> = self + .crates + .lock() + .unwrap() + .iter() + .filter(|c| { + filter_words.iter().all(|word| { + c.name.to_lowercase().contains(word) + || c.description + .clone() + .unwrap_or_default() + .to_lowercase() + .contains(word) + }) + }) + .cloned() + .collect_vec(); + self.results.crates = crates; + } + + pub fn scroll_up(&mut self) { + self.results.scroll_previous(); + } + + pub fn scroll_down(&mut self) { + self.results.scroll_next(); + } + + pub fn handle_key(&mut self, key: KeyEvent) { + self.input.handle_event(&CrosstermEvent::Key(key)); + } + + pub fn handle_filter_prompt_change(&mut self) { + self.filter = self.input.value().into(); + self.results.select(None); + } + + pub fn cursor_position(&self) -> Option { + self.prompt.cursor_position() + } + + pub fn increment_page(&mut self) { + if let Some(n) = self.total_num_crates { + let max_page_size = (n / self.page_size) + 1; + if self.page < max_page_size { + self.page = self.page.saturating_add(1).min(max_page_size); + self.reload_data(); + } + } + } + + pub fn decrement_page(&mut self) { + let min_page_size = 1; + if self.page > min_page_size { + self.page = self.page.saturating_sub(1).max(min_page_size); + self.reload_data(); + } + } + + pub fn clear_task_details_handle(&mut self, id: uuid::Uuid) -> Result<()> { + if let Some((_, handle)) = self.last_task_details_handle.remove_entry(&id) { + handle.abort() + } + Ok(()) + } + + pub fn is_prompt(&self) -> bool { + self.search_mode.is_focused() + } + + pub fn clear_all_previous_task_details_handles(&mut self) { + *self.full_crate_info.lock().unwrap() = None; + for (_, v) in self.last_task_details_handle.iter() { + v.abort() + } + self.last_task_details_handle.clear() + } + + pub fn submit_query(&mut self) { + self.clear_all_previous_task_details_handles(); + self.filter.clear(); + self.search = self.input.value().into(); + let _ = self.tx.send(Action::SwitchMode(Mode::PickerHideCrateInfo)); + } + + /// Reloads the list of crates based on the current search parameters, + /// updating the application state accordingly. This involves fetching + /// data asynchronously from the crates.io API and updating various parts of + /// the application state, such as the crates listing, current crate + /// info, and loading status. + pub fn reload_data(&mut self) { + self.prepare_reload(); + let search_params = self.create_search_parameters(); + self.request_search_results(search_params); + } + + /// Clears current search results and resets the UI to prepare for new data. + pub fn prepare_reload(&mut self) { + self.results.select(None); + *self.full_crate_info.lock().unwrap() = None; + *self.crate_response.lock().unwrap() = None; + } + + /// Creates the parameters required for the search task. + pub fn create_search_parameters(&self) -> crates_io_api_helper::SearchParameters { + crates_io_api_helper::SearchParameters { + search: self.search.clone(), + page: self.page.clamp(1, u64::MAX), + page_size: self.page_size, + crates: self.crates.clone(), + versions: self.versions.clone(), + loading_status: self.loading_status.clone(), + sort: self.sort.clone(), + tx: self.tx.clone(), + } + } + + /// Spawns an asynchronous task to fetch crate data from crates.io. + pub fn request_search_results(&self, params: crates_io_api_helper::SearchParameters) { + tokio::spawn(async move { + params.loading_status.store(true, Ordering::SeqCst); + if let Err(error_message) = crates_io_api_helper::request_search_results(¶ms).await + { + let _ = params.tx.send(Action::ShowErrorPopup(error_message)); + } + let _ = params.tx.send(Action::UpdateSearchTableResults); + params.loading_status.store(false, Ordering::SeqCst); + }); + } + + /// Spawns an asynchronous task to fetch crate details from crates.io based + /// on currently selected crate + pub fn request_crate_details(&mut self) { + if self.results.crates.is_empty() { + return; + } + if let Some(crate_name) = self.results.selected_crate_name() { + let tx = self.tx.clone(); + let crate_response = self.crate_response.clone(); + let loading_status = self.loading_status.clone(); + + // Spawn the async work to fetch crate details. + let uuid = uuid::Uuid::new_v4(); + let last_task_details_handle = tokio::spawn(async move { + info!("Requesting details for {crate_name}: {uuid}"); + loading_status.store(true, Ordering::SeqCst); + if let Err(error_message) = + crates_io_api_helper::request_crate_details(&crate_name, crate_response).await + { + let _ = tx.send(Action::ShowErrorPopup(error_message)); + }; + loading_status.store(false, Ordering::SeqCst); + info!("Retrieved details for {crate_name}: {uuid}"); + let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); + }); + self.last_task_details_handle + .insert(uuid, last_task_details_handle); + } + } + + /// Spawns an asynchronous task to fetch crate details from crates.io based + /// on currently selected crate + pub fn request_full_crate_details(&mut self) { + if self.results.crates.is_empty() { + return; + } + if let Some(crate_name) = self.results.selected_crate_name() { + let tx = self.tx.clone(); + let full_crate_info = self.full_crate_info.clone(); + let loading_status = self.loading_status.clone(); + + // Spawn the async work to fetch crate details. + let uuid = uuid::Uuid::new_v4(); + let last_task_details_handle = tokio::spawn(async move { + info!("Requesting details for {crate_name}: {uuid}"); + loading_status.store(true, Ordering::SeqCst); + if let Err(error_message) = + crates_io_api_helper::request_full_crate_details(&crate_name, full_crate_info) + .await + { + let _ = tx.send(Action::ShowErrorPopup(error_message)); + }; + loading_status.store(false, Ordering::SeqCst); + info!("Retrieved details for {crate_name}: {uuid}"); + let _ = tx.send(Action::ClearTaskDetailsHandle(uuid.to_string())); + }); + self.last_task_details_handle + .insert(uuid, last_task_details_handle); + } + } + + pub fn results_status(&self) -> String { + let selected = self.selected_with_page_context(); + let ncrates = self.total_num_crates.unwrap_or_default(); + format!("{}/{} Results", selected, ncrates) + } + + pub fn selected_with_page_context(&self) -> u64 { + self.results.selected().map_or(0, |n| { + (self.page.saturating_sub(1) * self.page_size) + n as u64 + 1 + }) + } + + pub fn page_number_status(&self) -> String { + let max_page_size = (self.total_num_crates.unwrap_or_default() / self.page_size) + 1; + format!("Page: {}/{}", self.page, max_page_size) + } + + pub fn enter_normal_mode(&mut self) { + self.search_mode = SearchMode::ResultsHideCrate; + if !self.results.crates.is_empty() && self.results.selected().is_none() { + self.results.select(Some(0)) + } + } + + pub fn enter_filter_insert_mode(&mut self) { + self.search_mode = SearchMode::Filter; + self.input = self.input.clone().with_value(self.filter.clone()); + } + + pub fn enter_search_insert_mode(&mut self) { + self.search_mode = SearchMode::Search; + self.input = self.input.clone().with_value(self.search.clone()); + } + + pub fn toggle_show_crate_info(&mut self) { + self.search_mode.toggle_show_crate_info(); + if self.search_mode.should_show_crate_info() { + self.request_crate_details() + } else { + self.clear_all_previous_task_details_handles(); + } + } + + fn toggle_sort_by_forward(&mut self) { + use crates_io_api::Sort as S; + self.sort = match self.sort { + S::Alphabetical => S::Relevance, + S::Relevance => S::Downloads, + S::Downloads => S::RecentDownloads, + S::RecentDownloads => S::RecentUpdates, + S::RecentUpdates => S::NewlyAdded, + S::NewlyAdded => S::Alphabetical, + }; + } + + fn toggle_sort_by_backward(&mut self) { + use crates_io_api::Sort as S; + self.sort = match self.sort { + S::Relevance => S::Alphabetical, + S::Downloads => S::Relevance, + S::RecentDownloads => S::Downloads, + S::RecentUpdates => S::RecentDownloads, + S::NewlyAdded => S::RecentUpdates, + S::Alphabetical => S::NewlyAdded, + }; + } + + pub fn toggle_sort_by(&mut self, reload: bool, forward: bool) -> Result<()> { + if forward { + self.toggle_sort_by_forward() + } else { + self.toggle_sort_by_backward() + }; + if reload { + self.tx.send(Action::ReloadData)?; + } + Ok(()) + } + + fn is_focused(&self) -> bool { + self.mode.is_picker() + } +} + +pub struct SearchPageWidget { + pub mode: Mode, +} + +impl SearchPageWidget { + pub fn new(mode: Mode) -> Self { + Self { mode } + } + + fn render_crate_info(&self, area: Rect, buf: &mut Buffer, state: &mut SearchPage) { + if let Some(ci) = state.crate_response.lock().unwrap().clone() { + CrateInfoTableWidget::new(ci).render(area, buf, &mut state.crate_info); + } + } +} + +impl StatefulWidget for SearchPageWidget { + type State = SearchPage; + + fn render( + self, + area: ratatui::prelude::Rect, + buf: &mut ratatui::prelude::Buffer, + state: &mut Self::State, + ) { + let area = if state.search_mode.is_results_show_crate() { + let [area, info] = + Layout::vertical([Constraint::Min(0), Constraint::Max(15)]).areas(area); + self.render_crate_info(info, buf, state); + area + } else { + area + }; + + SearchResultsWidget::new(!state.is_prompt() && state.is_focused()).render( + area, + buf, + &mut state.results, + ); + + Line::from(state.page_number_status()) + .left_aligned() + .render( + area.inner(&Margin { + horizontal: 1, + vertical: 2, + }), + buf, + ); + + Line::from(state.results_status()).right_aligned().render( + area.inner(&Margin { + horizontal: 1, + vertical: 2, + }), + buf, + ); + } +} diff --git a/src/widgets/search_results.rs b/src/widgets/search_results.rs new file mode 100644 index 0000000..1aec390 --- /dev/null +++ b/src/widgets/search_results.rs @@ -0,0 +1,218 @@ +use crates_io_api::Crate; +use itertools::Itertools; +use num_format::{Locale, ToFormattedString}; +use ratatui::{prelude::*, widgets::*}; +use unicode_width::UnicodeWidthStr; + +use crate::config; + +#[derive(Debug, Default)] +pub struct SearchResults { + pub crates: Vec, + pub versions: Vec, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, +} + +impl SearchResults { + pub fn selected_crate_name(&self) -> Option { + self.selected() + .and_then(|index| self.crates.get(index)) + .filter(|krate| !krate.name.is_empty()) + .map(|krate| krate.name.clone()) + } + + pub fn selected(&self) -> Option { + self.table_state.selected() + } + + pub fn content_length(&mut self, content_length: usize) { + self.scrollbar_state = self.scrollbar_state.content_length(content_length) + } + + pub fn select(&mut self, index: Option) { + self.table_state.select(index) + } + + pub fn scroll_next(&mut self) { + let wrap_index = self.crates.len().max(1); + let next = self + .table_state + .selected() + .map_or(0, |i| (i + 1) % wrap_index); + self.scroll_to(next); + } + + pub fn scroll_previous(&mut self) { + let last = self.crates.len().saturating_sub(1); + let wrap_index = self.crates.len().max(1); + let previous = self + .table_state + .selected() + .map_or(last, |i| (i + last) % wrap_index); + self.scroll_to(previous); + } + + pub fn scroll_to_top(&mut self) { + self.scroll_to(0); + } + + pub fn scroll_to_bottom(&mut self) { + let bottom = self.crates.len().saturating_sub(1); + self.scroll_to(bottom); + } + + fn scroll_to(&mut self, index: usize) { + if self.crates.is_empty() { + self.table_state.select(None) + } else { + self.table_state.select(Some(index)); + self.scrollbar_state = self.scrollbar_state.position(index); + } + } +} + +pub struct SearchResultsWidget { + highlight: bool, +} + +impl SearchResultsWidget { + pub fn new(highlight: bool) -> Self { + Self { highlight } + } +} + +impl StatefulWidget for SearchResultsWidget { + type State = SearchResults; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + use Constraint::*; + const TABLE_HEADER_HEIGHT: u16 = 3; + const COLUMN_SPACING: u16 = 3; + + let [table_area, scrollbar_area] = Layout::horizontal([Fill(1), Length(1)]).areas(area); + let [_, scrollbar_area] = + Layout::vertical([Length(TABLE_HEADER_HEIGHT), Fill(1)]).areas(scrollbar_area); + + Scrollbar::default() + .track_symbol(Some(" ")) + .thumb_symbol("▐") + .begin_symbol(None) + .end_symbol(None) + .track_style(config::get().color.base06) + .render(scrollbar_area, buf, &mut state.scrollbar_state); + + let highlight_symbol = if self.highlight { + " █ " + } else { + " \u{2022} " + }; + + let column_widths = [Max(20), Fill(1), Max(11)]; + + // Emulate the table layout calculations using Layout so we can render the vertical borders + // in the space between the columns and can wrap the description field based on the actual + // width of the description column + let highlight_symbol_width = highlight_symbol.width() as u16; + let [_highlight_column, table_columns] = + Layout::horizontal([Length(highlight_symbol_width), Fill(1)]).areas(table_area); + let column_layout = Layout::horizontal(column_widths).spacing(COLUMN_SPACING); + let [_name_column, description_column, _downloads_column] = + column_layout.areas(table_columns); + let spacers: [Rect; 4] = column_layout.spacers(table_columns); + + let vertical_pad = |line| Text::from(vec!["".into(), line, "".into()]); + + let header_cells = ["Name", "Description", "Downloads"] + .map(|h| h.bold().into()) + .map(vertical_pad); + let header = Row::new(header_cells) + .fg(config::get().color.base05) + .bg(config::get().color.base00) + .height(TABLE_HEADER_HEIGHT); + + let description_column_width = description_column.width as usize; + let selected_index = state.selected().unwrap_or_default(); + let rows = state + .crates + .iter() + .enumerate() + .map(|(index, krate)| { + row_from_crate(krate, description_column_width, index, selected_index) + }) + .collect_vec(); + + let table = Table::new(rows, column_widths) + .header(header) + .column_spacing(COLUMN_SPACING) + .highlight_symbol(vertical_pad(highlight_symbol.into())) + .highlight_style(config::get().color.base05) + .highlight_spacing(HighlightSpacing::Always); + + StatefulWidget::render(table, table_area, buf, &mut state.table_state); + + render_table_borders(state, spacers, buf); + } +} + +fn row_from_crate( + krate: &Crate, + description_column_width: usize, + index: usize, + selected_index: usize, +) -> Row { + let mut description = textwrap::wrap( + &krate.description.clone().unwrap_or_default(), + description_column_width, + ) + .iter() + .map(|s| Line::from(s.to_string())) + .collect_vec(); + description.insert(0, "".into()); + description.push("".into()); + let vertical_padded = |line| Text::from(vec!["".into(), line, "".into()]); + let crate_name = Line::from(krate.name.clone()); + let downloads = Line::from(krate.downloads.to_formatted_string(&Locale::en)).right_aligned(); + let description_height = description.len() as u16; + Row::new([ + vertical_padded(crate_name), + Text::from(description), + vertical_padded(downloads), + ]) + .height(description_height) + .fg(config::get().color.base05) + .bg(bg_color(index, selected_index)) +} + +fn bg_color(index: usize, selected_index: usize) -> Color { + if index == selected_index { + config::get().color.base02 + } else { + match index % 2 { + 0 => config::get().color.base00, + 1 => config::get().color.base01, + _ => unreachable!("mod 2 is always 0 or 1"), + } + } +} + +fn render_table_borders(state: &mut SearchResults, spacers: [Rect; 4], buf: &mut Buffer) { + // only render margins when there's items in the table + if !state.crates.is_empty() { + // don't render margin for the first column + for space in spacers.iter().skip(1).copied() { + Text::from( + std::iter::once(" ".into()) + .chain(std::iter::once(" ".into())) + .chain(std::iter::once(" ".into())) + .chain( + std::iter::repeat(" │".fg(config::get().color.base0f)) + .take(space.height as usize), + ) + .map(Line::from) + .collect_vec(), + ) + .render(space, buf); + } + } +} diff --git a/src/widgets/search_results_table.rs b/src/widgets/search_results_table.rs deleted file mode 100644 index 3848023..0000000 --- a/src/widgets/search_results_table.rs +++ /dev/null @@ -1,213 +0,0 @@ -use itertools::Itertools; -use num_format::{Locale, ToFormattedString}; -use ratatui::{prelude::*, widgets::*}; - -use crate::config; - -#[derive(Debug, Default)] -pub struct SearchResultsTable { - pub crates: Vec, - pub versions: Vec, - pub table_state: TableState, - pub scrollbar_state: ScrollbarState, -} - -impl SearchResultsTable { - pub fn selected_crate_name(&self) -> Option { - self.selected() - .and_then(|index| self.crates.get(index)) - .filter(|crate_| !crate_.name.is_empty()) - .map(|crate_| crate_.name.clone()) - } - - pub fn content_length(&mut self, content_length: usize) { - self.scrollbar_state = self.scrollbar_state.content_length(content_length) - } - - pub fn select(&mut self, index: Option) { - self.table_state.select(index) - } - - pub fn selected(&self) -> Option { - self.table_state.selected() - } - - pub fn scroll_next(&mut self, count: usize) { - if self.crates.is_empty() { - self.table_state.select(None) - } else { - // wrapping behavior - let i = self - .table_state - .selected() - .map_or(0, |i| (i + count) % self.crates.len()); - self.table_state.select(Some(i)); - self.scrollbar_state = self.scrollbar_state.position(i); - } - } - - pub fn scroll_previous(&mut self, count: usize) { - if self.crates.is_empty() { - self.table_state.select(None) - } else { - // wrapping behavior - let i = self - .table_state - .selected() - .map_or(self.crates.len().saturating_sub(1), |i| { - if i == 0 { - self.crates.len().saturating_sub(1) - } else { - i.saturating_sub(count) - } - }); - self.table_state.select(Some(i)); - self.scrollbar_state = self.scrollbar_state.position(i); - } - } - - pub fn scroll_to_top(&mut self) { - if self.crates.is_empty() { - self.table_state.select(None) - } else { - self.table_state.select(Some(0)); - self.scrollbar_state = self.scrollbar_state.position(0); - } - } - - pub fn scroll_to_bottom(&mut self) { - if self.crates.is_empty() { - self.table_state.select(None) - } else { - self.table_state.select(Some(self.crates.len() - 1)); - self.scrollbar_state = self.scrollbar_state.position(self.crates.len() - 1); - } - } -} - -pub struct SearchResultsTableWidget { - highlight: bool, -} - -impl SearchResultsTableWidget { - pub fn new(highlight: bool) -> Self { - Self { highlight } - } -} - -impl StatefulWidget for SearchResultsTableWidget { - type State = SearchResultsTable; - - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let [area, scrollbar_area] = - Layout::horizontal([Constraint::Fill(1), Constraint::Length(1)]).areas(area); - Scrollbar::default() - .track_symbol(Some(" ")) - .begin_symbol(None) - .end_symbol(None) - .track_style(config::get().color.base06) - .render(scrollbar_area, buf, &mut state.scrollbar_state); - - let widths = [ - Constraint::Length(1), - Constraint::Max(20), - Constraint::Min(0), - Constraint::Max(10), - ]; - let (areas, spacers) = - Layout::horizontal(widths) - .spacing(1) - .split_with_spacers(area.inner(&Margin { - horizontal: 1, - vertical: 0, - })); - let description_area = areas[2]; - let text_wrap_width = description_area.width as usize; - - let selected = state.selected().unwrap_or_default(); - let table_widget = { - let header = Row::new( - ["Name", "Description", "Downloads"] - .iter() - .map(|h| Text::from(vec!["".into(), Line::from(h.bold()), "".into()])), - ) - .fg(config::get().color.base05) - .bg(config::get().color.base00) - .height(3); - let highlight_symbol = if self.highlight { - " █ " - } else { - " \u{2022} " - }; - - let rows = state.crates.iter().enumerate().map(|(i, item)| { - let mut desc = textwrap::wrap( - &item.description.clone().unwrap_or_default(), - text_wrap_width, - ) - .iter() - .map(|s| Line::from(s.to_string())) - .collect_vec(); - desc.insert(0, "".into()); - let height = desc.len(); - Row::new([ - Text::from(vec!["".into(), Line::from(item.name.clone()), "".into()]), - Text::from(desc), - Text::from(vec![ - "".into(), - Line::from(item.downloads.to_formatted_string(&Locale::en)), - "".into(), - ]), - ]) - .style({ - let s = Style::default() - .fg(config::get().color.base05) - .bg(match i % 2 { - 0 => config::get().color.base00, - 1 => config::get().color.base01, - _ => unreachable!("Cannot reach this line"), - }); - if i == selected { - s.bg(config::get().color.base02) - } else { - s - } - }) - .height(height.saturating_add(1) as u16) - }); - - let widths = [Constraint::Max(20), Constraint::Min(0), Constraint::Max(10)]; - Table::new(rows, widths) - .header(header) - .column_spacing(1) - .highlight_symbol(Text::from(vec![ - "".into(), - highlight_symbol.into(), - "".into(), - ])) - .highlight_style(config::get().color.base05) - .highlight_spacing(HighlightSpacing::Always) - }; - - StatefulWidget::render(table_widget, area, buf, &mut state.table_state); - - // only render margins when there's items in the table - if !state.crates.is_empty() { - // don't render margin for the first column - for space in spacers.iter().skip(2).copied() { - Text::from( - std::iter::once(" ".into()) - .chain(std::iter::once(" ".into())) - .chain(std::iter::once(" ".into())) - .chain( - std::iter::repeat("│".fg(config::get().color.base0f)) - .take(space.height as usize), - ) - .map(Line::from) - .collect_vec(), - ) - .render(space, buf); - } - } - } -} diff --git a/src/widgets/status_bar.rs b/src/widgets/status_bar.rs new file mode 100644 index 0000000..c252cd2 --- /dev/null +++ b/src/widgets/status_bar.rs @@ -0,0 +1,146 @@ +use ratatui::{prelude::*, widgets::*}; + +use crate::{app::Mode, command::Command, config}; + +pub struct StatusBarWidget { + text: String, + mode: Mode, + sort: crates_io_api::Sort, +} + +impl StatusBarWidget { + pub fn new(mode: Mode, sort: crates_io_api::Sort, text: String) -> Self { + Self { text, mode, sort } + } +} + +impl Widget for StatusBarWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + self.status().render(area, buf); + } +} + +impl StatusBarWidget { + fn input_text(&self) -> Line { + if self.mode.is_picker() { + Line::from(vec![ + self.text.clone().into(), + " (".into(), + format!("{:?}", self.sort.clone()).fg(config::get().color.base0d), + ")".into(), + ]) + } else { + "".into() + } + } + + fn status(&self) -> Block { + let line = if self.mode.is_filter() { + let help = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) + .into_iter() + .next() + .unwrap_or_default(); + vec![ + "Enter".bold(), + " to submit, ".into(), + help.bold(), + " for help".into(), + ] + } else if self.mode.is_search() { + let toggle_sort = config::get() + .key_bindings + .get_config_for_command( + Mode::Search, + Command::ToggleSortBy { + reload: false, + forward: true, + }, + ) + .into_iter() + .next() + .unwrap_or_default(); + let help = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) + .into_iter() + .next() + .unwrap_or_default(); + vec![ + toggle_sort.bold(), + " to toggle sort, ".into(), + "Enter".bold(), + " to submit, ".into(), + help.bold(), + " for help".into(), + ] + } else if self.mode.is_summary() { + let help = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) + .into_iter() + .next() + .unwrap_or_default(); + let open_in_browser = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::OpenCratesIOUrlInBrowser) + .into_iter() + .next() + .unwrap_or_default(); + let search = config::get() + .key_bindings + .get_config_for_command(Mode::Common, Command::NextTab) + .into_iter() + .next() + .unwrap_or_default(); + vec![ + open_in_browser.bold(), + " to open in browser, ".into(), + search.bold(), + " to enter search, ".into(), + help.bold(), + " for help".into(), + ] + } else if self.mode.is_help() { + vec!["ESC".bold(), " to return".into()] + } else { + let search = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Search)) + .into_iter() + .next() + .unwrap_or_default(); + let filter = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Filter)) + .into_iter() + .next() + .unwrap_or_default(); + let help = config::get() + .key_bindings + .get_config_for_command(self.mode, Command::SwitchMode(Mode::Help)) + .into_iter() + .next() + .unwrap_or_default(); + vec![ + search.bold(), + " to search, ".into(), + filter.bold(), + " to filter, ".into(), + help.bold(), + " for help".into(), + ] + }; + let border_color = match self.mode { + Mode::Search => config::get().color.base0a, + Mode::Filter => config::get().color.base0b, + _ => config::get().color.base06, + }; + Block::default() + .title(block::Title::from(Line::from(line)).alignment(Alignment::Right)) + .title(block::Title::from(self.input_text()).alignment(Alignment::Left)) + .fg(config::get().color.base05) + .border_style(border_color) + } +} diff --git a/src/widgets/summary.rs b/src/widgets/summary.rs index c60bdd1..ee37e46 100644 --- a/src/widgets/summary.rs +++ b/src/widgets/summary.rs @@ -1,8 +1,15 @@ +use color_eyre::Result; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + use itertools::Itertools; use ratatui::{layout::Flex, prelude::*, widgets::*}; use strum::{Display, EnumIs, EnumIter, FromRepr}; +use tokio::sync::mpsc::UnboundedSender; -use crate::config; +use crate::{action::Action, config, crates_io_api_helper}; #[derive(Default, Debug, Clone, Copy, EnumIs, FromRepr, Display, EnumIter)] pub enum SummaryMode { @@ -15,7 +22,7 @@ pub enum SummaryMode { PopularCategories, } -const HIGHLIGHT_SYMBOL: &str = "█"; +const HIGHLIGHT_SYMBOL: &str = " █ "; impl SummaryMode { /// Get the previous tab, if there is no previous tab return the current tab. @@ -45,15 +52,39 @@ impl SummaryMode { } } -#[derive(Default, Debug, Clone)] +#[derive(Debug, Clone)] pub struct Summary { pub state: [ListState; 6], pub last_selection: [usize; 6], pub mode: SummaryMode, pub summary_data: Option, + + /// A thread-safe shared container holding the detailed information about + /// the currently selected crate; this can be `None` if no crate is + /// selected. + pub data: Arc>>, + + /// Sender end of an asynchronous channel for dispatching actions from + /// various parts of the app to be handled by the event loop. + tx: UnboundedSender, + + /// A thread-safe indicator of whether data is currently being loaded, + /// allowing different parts of the app to know if it's in a loading state. + loading_status: Arc, } impl Summary { + pub fn new(tx: UnboundedSender, loading_status: Arc) -> Self { + Self { + tx, + loading_status, + state: Default::default(), + last_selection: Default::default(), + mode: Default::default(), + summary_data: Default::default(), + data: Default::default(), + } + } pub fn mode(&self) -> SummaryMode { self.mode } @@ -129,6 +160,30 @@ impl Summary { let new_state = self.get_state_mut(self.mode); *new_state.selected_mut() = Some(i); } + + pub fn request(&self) -> Result<()> { + let tx = self.tx.clone(); + let loading_status = self.loading_status.clone(); + let summary = self.data.clone(); + tokio::spawn(async move { + loading_status.store(true, Ordering::SeqCst); + if let Err(error_message) = crates_io_api_helper::request_summary(summary).await { + let _ = tx.send(Action::ShowErrorPopup(error_message)); + } + loading_status.store(false, Ordering::SeqCst); + let _ = tx.send(Action::UpdateSummary); + let _ = tx.send(Action::ScrollDown); + }); + Ok(()) + } + + pub fn update(&mut self) { + if let Some(summary) = self.data.lock().unwrap().clone() { + self.summary_data = Some(summary); + } else { + self.summary_data = None; + } + } } impl Summary {