use async_trait::async_trait;
use rat_cursor::HasScreenCursor;
use rat_widget::{
choice::{Choice, ChoiceState},
event::{HandleEvent, Popup, Regular, ct_event},
focus::{FocusBuilder, FocusFlag, HasFocus},
popup::Placement,
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
widgets::{Block, BorderType, StatefulWidget, Widget},
};
use std::sync::Arc;
use throbber_widgets_tui::ThrobberState;
use tracing::instrument;
use tracing::trace;
use crate::{
app::GITHUB_CLIENT,
errors::AppError,
ui::{
Action, AppState, MergeStrategy,
components::{Component, help::HelpElementKind, issue_list::MainScreen},
layout::Layout,
utils::{get_border_style, get_loader_area},
},
};
const OPTIONS: [&str; 3] = ["Open", "Closed", "All"];
pub const HELP: &[HelpElementKind] = &[
crate::help_text!("Search Bar Help"),
crate::help_keybind!("Type", "issue text in Search"),
crate::help_keybind!(
"Type",
"labels in Search Labels (separate multiple with ';')"
),
crate::help_keybind!("Tab / Shift+Tab", "move between inputs and status selector"),
crate::help_keybind!("Enter", "run search"),
];
pub struct TextSearch {
pub search_state: rat_widget::text_input::TextInputState,
pub label_state: rat_widget::text_input::TextInputState,
cstate: ChoiceState,
state: State,
action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
loader_state: ThrobberState,
repo: String,
owner: String,
screen: MainScreen,
focus: FocusFlag,
area: Rect,
index: usize,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum State {
Loading,
#[default]
Loaded,
}
impl TextSearch {
pub fn new(AppState { repo, owner, .. }: AppState) -> Self {
Self {
repo,
owner,
search_state: Default::default(),
label_state: Default::default(),
loader_state: Default::default(),
state: Default::default(),
cstate: Default::default(),
action_tx: None,
screen: MainScreen::default(),
focus: FocusFlag::new().with_name("search_bar"),
area: Rect::default(),
index: 0,
}
}
fn render_w(&mut self, layout: Layout, buf: &mut Buffer) {
let total_area = layout
.text_search
.union(layout.label_search.union(layout.status_dropdown));
self.area = total_area;
let contents = (1..).zip(OPTIONS).collect::<Vec<_>>();
let text_input = rat_widget::text_input::TextInput::new().block(
Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(get_border_style(&self.search_state))
.title(format!("[{}] Search", self.index)),
);
let label = rat_widget::text_input::TextInput::new().block(
Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(get_border_style(&self.label_state))
.title("Search Labels"),
);
let (widget, popup) = Choice::new()
.items(contents)
.popup_placement(Placement::Below)
.focus_style(Style::default())
.select_style(Style::default())
.button_style(Style::default())
.style(Style::default())
.select_marker('>')
.into_widgets();
let block = Block::bordered()
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(get_border_style(&self.cstate));
let binner = block.inner(layout.status_dropdown);
block.render(layout.status_dropdown, buf);
popup.render(layout.status_dropdown, buf, &mut self.cstate);
widget.render(binner, buf, &mut self.cstate);
text_input.render(layout.text_search, buf, &mut self.search_state);
label.render(layout.label_search, buf, &mut self.label_state);
if self.state == State::Loading {
let area = get_loader_area(
Block::bordered()
.border_type(BorderType::Rounded)
.inner(layout.text_search),
);
let full = throbber_widgets_tui::Throbber::default()
.label("Loading")
.style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
.throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
.use_type(throbber_widgets_tui::WhichUse::Spin);
StatefulWidget::render(full, area, buf, &mut self.loader_state);
}
}
#[instrument(skip(self, action_tx))]
async fn execute_search(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
let mut search = self.search_state.text().to_string();
let label = self.label_state.text();
if !label.is_empty() {
let label_q = label.split(';').map(|s| format!("label:{s}"));
search.push(' ');
search.push_str(&label_q.collect::<Vec<_>>().join(" "));
}
let status = self.cstate.selected();
trace!(status, "Searching with status");
if let Some(status) = status
&& status != 2
{
search.push_str(&format!(" is:{}", OPTIONS[status].to_lowercase()));
}
let repo_q = format!("repo:{}/{}", self.owner, self.repo);
search.push(' ');
search.push_str(&repo_q);
search.push_str(" is:issue");
trace!(search, "Searching with query");
self.state = State::Loading;
tokio::spawn(async move {
let client = GITHUB_CLIENT.get().ok_or_else(|| {
AppError::Other(anyhow::anyhow!("github client is not initialized"))
})?;
let page = client
.search()
.issues_and_pull_requests(&search)
.page(1_u32)
.per_page(10)
.sort("created")
.order("desc")
.send()
.await?;
action_tx
.send(Action::NewPage(Arc::new(page), MergeStrategy::Replace))
.await?;
action_tx.send(Action::FinishedLoading).await?;
Ok::<(), crate::errors::AppError>(())
});
}
fn self_is_focused(&self) -> bool {
self.search_state.is_focused() || self.label_state.is_focused() || self.cstate.is_focused()
}
}
impl HasFocus for TextSearch {
fn build(&self, builder: &mut FocusBuilder) {
let tag = builder.start(self);
builder.widget(&self.search_state);
builder.widget(&self.label_state);
builder.widget(&self.cstate);
builder.end(tag);
}
fn focus(&self) -> FocusFlag {
self.focus.clone()
}
fn area(&self) -> ratatui::layout::Rect {
self.area
}
}
#[async_trait(?Send)]
impl Component for TextSearch {
fn render(&mut self, area: Layout, buf: &mut Buffer) {
self.render_w(area, buf);
}
fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
self.action_tx = Some(action_tx);
}
async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
match event {
Action::ChangeIssueScreen(screen) => {
self.screen = screen;
}
Action::RefreshIssueList => {
if self.screen != MainScreen::CreateIssue
&& self.screen != MainScreen::DetailsFullscreen
&& self.state != State::Loading
&& let Some(action_tx) = self.action_tx.clone()
{
self.execute_search(action_tx).await;
}
}
Action::AppEvent(ref event) => {
if self.screen == MainScreen::CreateIssue
|| self.screen == MainScreen::DetailsFullscreen
{
return Ok(());
}
if self.self_is_focused() {
match event {
ct_event!(keycode press Enter) => {
if let Some(action_tx) = self.action_tx.clone() {
self.execute_search(action_tx).await;
return Ok(());
}
}
_ => {}
}
}
self.label_state.handle(event, Regular);
self.search_state.handle(event, Regular);
self.cstate.handle(event, Popup);
}
Action::FinishedLoading => {
self.state = State::Loaded;
}
Action::Tick => {
if self.state == State::Loading {
self.loader_state.calc_next();
}
}
_ => {}
}
Ok(())
}
fn cursor(&self) -> Option<(u16, u16)> {
self.search_state
.screen_cursor()
.or(self.label_state.screen_cursor())
.or(self.cstate.screen_cursor())
}
fn is_animating(&self) -> bool {
self.screen != MainScreen::CreateIssue
&& self.screen != MainScreen::DetailsFullscreen
&& self.state == State::Loading
}
fn should_render(&self) -> bool {
self.screen != MainScreen::CreateIssue && self.screen != MainScreen::DetailsFullscreen
}
fn set_index(&mut self, index: usize) {
self.index = index;
}
fn capture_focus_event(&self, event: &crossterm::event::Event) -> bool {
self.self_is_focused()
&& !matches!(
event,
ct_event!(keycode press Tab) | ct_event!(keycode press BackTab)
)
}
fn set_global_help(&self) {
if let Some(action_tx) = &self.action_tx {
let _ = action_tx.try_send(Action::SetHelp(HELP));
}
}
}