Skip to main content

gitv_tui/ui/components/
search_bar.rs

1use async_trait::async_trait;
2use rat_cursor::HasScreenCursor;
3use rat_widget::{
4    choice::{Choice, ChoiceState},
5    event::{HandleEvent, Popup, Regular, ct_event},
6    focus::{FocusBuilder, FocusFlag, HasFocus},
7    popup::Placement,
8};
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::Style,
13    widgets::{Block, BorderType, StatefulWidget, Widget},
14};
15use std::sync::Arc;
16use throbber_widgets_tui::ThrobberState;
17use tracing::instrument;
18use tracing::trace;
19
20use crate::{
21    app::GITHUB_CLIENT,
22    errors::AppError,
23    ui::{
24        Action, AppState, MergeStrategy,
25        components::{Component, help::HelpElementKind, issue_list::MainScreen},
26        layout::Layout,
27        utils::{get_border_style, get_loader_area},
28    },
29};
30
31const OPTIONS: [&str; 3] = ["Open", "Closed", "All"];
32pub const HELP: &[HelpElementKind] = &[
33    crate::help_text!("Search Bar Help"),
34    crate::help_keybind!("Type", "issue text in Search"),
35    crate::help_keybind!(
36        "Type",
37        "labels in Search Labels (separate multiple with ';')"
38    ),
39    crate::help_keybind!("Tab / Shift+Tab", "move between inputs and status selector"),
40    crate::help_keybind!("Enter", "run search"),
41];
42
43pub struct TextSearch {
44    search_state: rat_widget::text_input::TextInputState,
45    label_state: rat_widget::text_input::TextInputState,
46    cstate: ChoiceState,
47    state: State,
48    action_tx: Option<tokio::sync::mpsc::Sender<Action>>,
49    loader_state: ThrobberState,
50    repo: String,
51    owner: String,
52    screen: MainScreen,
53    focus: FocusFlag,
54    area: Rect,
55    index: usize,
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
59enum State {
60    Loading,
61    #[default]
62    Loaded,
63}
64
65impl TextSearch {
66    pub fn new(AppState { repo, owner, .. }: AppState) -> Self {
67        Self {
68            repo,
69            owner,
70            search_state: Default::default(),
71            label_state: Default::default(),
72            loader_state: Default::default(),
73            state: Default::default(),
74            cstate: Default::default(),
75            action_tx: None,
76            screen: MainScreen::default(),
77            focus: FocusFlag::new().with_name("search_bar"),
78            area: Rect::default(),
79            index: 0,
80        }
81    }
82
83    fn render_w(&mut self, layout: Layout, buf: &mut Buffer) {
84        let total_area = layout
85            .text_search
86            .union(layout.label_search.union(layout.status_dropdown));
87        self.area = total_area;
88        let contents = (1..).zip(OPTIONS).collect::<Vec<_>>();
89        let text_input = rat_widget::text_input::TextInput::new().block(
90            Block::bordered()
91                .border_type(ratatui::widgets::BorderType::Rounded)
92                .border_style(get_border_style(&self.search_state))
93                .title(format!("[{}] Search", self.index)),
94        );
95        let label = rat_widget::text_input::TextInput::new().block(
96            Block::bordered()
97                .border_type(ratatui::widgets::BorderType::Rounded)
98                .border_style(get_border_style(&self.label_state))
99                .title("Search Labels"),
100        );
101        let (widget, popup) = Choice::new()
102            .items(contents)
103            .popup_placement(Placement::Below)
104            .focus_style(Style::default())
105            .select_style(Style::default())
106            .button_style(Style::default())
107            .style(Style::default())
108            .select_marker('>')
109            .into_widgets();
110        let block = Block::bordered()
111            .border_type(ratatui::widgets::BorderType::Rounded)
112            .border_style(get_border_style(&self.cstate));
113        let binner = block.inner(layout.status_dropdown);
114
115        block.render(layout.status_dropdown, buf);
116        popup.render(layout.status_dropdown, buf, &mut self.cstate);
117        widget.render(binner, buf, &mut self.cstate);
118        text_input.render(layout.text_search, buf, &mut self.search_state);
119        label.render(layout.label_search, buf, &mut self.label_state);
120        if self.state == State::Loading {
121            let area = get_loader_area(
122                Block::bordered()
123                    .border_type(BorderType::Rounded)
124                    .inner(layout.text_search),
125            );
126            let full = throbber_widgets_tui::Throbber::default()
127                .label("Loading")
128                .style(ratatui::style::Style::default().fg(ratatui::style::Color::Cyan))
129                .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE)
130                .use_type(throbber_widgets_tui::WhichUse::Spin);
131            StatefulWidget::render(full, area, buf, &mut self.loader_state);
132        }
133    }
134
135    #[instrument(skip(self, action_tx))]
136    async fn execute_search(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
137        let mut search = self.search_state.text().to_string();
138        let label = self.label_state.text();
139        if !label.is_empty() {
140            let label_q = label.split(';').map(|s| format!("label:{s}"));
141            search.push(' ');
142            search.push_str(&label_q.collect::<Vec<_>>().join(" "));
143        }
144        let status = self.cstate.selected();
145        trace!(status, "Searching with status");
146        if let Some(status) = status
147            && status != 2
148        {
149            search.push_str(&format!(" is:{}", OPTIONS[status].to_lowercase()));
150        }
151        let repo_q = format!("repo:{}/{}", self.owner, self.repo);
152        search.push(' ');
153        search.push_str(&repo_q);
154        search.push_str(" is:issue");
155        trace!(search, "Searching with query");
156        self.state = State::Loading;
157        tokio::spawn(async move {
158            let client = GITHUB_CLIENT.get().ok_or_else(|| {
159                AppError::Other(anyhow::anyhow!("github client is not initialized"))
160            })?;
161            let page = client
162                .search()
163                .issues_and_pull_requests(&search)
164                .page(1_u32)
165                .per_page(10)
166                .sort("created")
167                .order("desc")
168                .send()
169                .await?;
170            action_tx
171                .send(Action::NewPage(Arc::new(page), MergeStrategy::Replace))
172                .await
173                .map_err(|_| AppError::TokioMpsc)?;
174            action_tx
175                .send(Action::FinishedLoading)
176                .await
177                .map_err(|_| AppError::TokioMpsc)?;
178            Ok::<(), crate::errors::AppError>(())
179        });
180    }
181
182    ///NOTE: Its named this way to not conflict with the `has_focus`
183    /// fn from the impl_has_focus! macro
184    fn self_is_focused(&self) -> bool {
185        self.search_state.is_focused() || self.label_state.is_focused() || self.cstate.is_focused()
186    }
187}
188
189impl HasFocus for TextSearch {
190    fn build(&self, builder: &mut FocusBuilder) {
191        let tag = builder.start(self);
192        builder.widget(&self.search_state);
193        builder.widget(&self.label_state);
194        builder.widget(&self.cstate);
195        builder.end(tag);
196    }
197    fn focus(&self) -> FocusFlag {
198        self.focus.clone()
199    }
200    fn area(&self) -> ratatui::layout::Rect {
201        self.area
202    }
203}
204
205#[async_trait(?Send)]
206impl Component for TextSearch {
207    fn render(&mut self, area: Layout, buf: &mut Buffer) {
208        self.render_w(area, buf);
209    }
210
211    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
212        self.action_tx = Some(action_tx);
213    }
214    async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
215        match event {
216            Action::ChangeIssueScreen(screen) => {
217                self.screen = screen;
218            }
219            Action::RefreshIssueList => {
220                if self.screen != MainScreen::CreateIssue
221                    && self.screen != MainScreen::DetailsFullscreen
222                    && self.state != State::Loading
223                    && let Some(action_tx) = self.action_tx.clone()
224                {
225                    self.execute_search(action_tx).await;
226                }
227            }
228            Action::AppEvent(ref event) => {
229                if self.screen == MainScreen::CreateIssue
230                    || self.screen == MainScreen::DetailsFullscreen
231                {
232                    return Ok(());
233                }
234                if self.self_is_focused() {
235                    match event {
236                        ct_event!(keycode press Enter) => {
237                            if let Some(action_tx) = self.action_tx.clone() {
238                                self.execute_search(action_tx).await;
239                                return Ok(());
240                            }
241                        }
242                        _ => {}
243                    }
244                }
245                self.label_state.handle(event, Regular);
246                self.search_state.handle(event, Regular);
247                self.cstate.handle(event, Popup);
248            }
249            Action::FinishedLoading => {
250                self.state = State::Loaded;
251            }
252            Action::Tick => {
253                if self.state == State::Loading {
254                    self.loader_state.calc_next();
255                }
256            }
257            _ => {}
258        }
259        Ok(())
260    }
261    fn cursor(&self) -> Option<(u16, u16)> {
262        self.search_state
263            .screen_cursor()
264            .or(self.label_state.screen_cursor())
265            .or(self.cstate.screen_cursor())
266    }
267
268    fn is_animating(&self) -> bool {
269        self.screen != MainScreen::CreateIssue
270            && self.screen != MainScreen::DetailsFullscreen
271            && self.state == State::Loading
272    }
273
274    fn should_render(&self) -> bool {
275        self.screen != MainScreen::CreateIssue && self.screen != MainScreen::DetailsFullscreen
276    }
277    fn set_index(&mut self, index: usize) {
278        self.index = index;
279    }
280
281    fn capture_focus_event(&self, event: &crossterm::event::Event) -> bool {
282        self.self_is_focused()
283            && !matches!(
284                event,
285                ct_event!(keycode press Tab) | ct_event!(keycode press BackTab)
286            )
287    }
288
289    fn set_global_help(&self) {
290        if let Some(action_tx) = &self.action_tx {
291            let _ = action_tx.try_send(Action::SetHelp(HELP));
292        }
293    }
294}