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            action_tx.send(Action::FinishedLoading).await?;
174            Ok::<(), crate::errors::AppError>(())
175        });
176    }
177
178    ///NOTE: Its named this way to not conflict with the `has_focus`
179    /// fn from the impl_has_focus! macro
180    fn self_is_focused(&self) -> bool {
181        self.search_state.is_focused() || self.label_state.is_focused() || self.cstate.is_focused()
182    }
183}
184
185impl HasFocus for TextSearch {
186    fn build(&self, builder: &mut FocusBuilder) {
187        let tag = builder.start(self);
188        builder.widget(&self.search_state);
189        builder.widget(&self.label_state);
190        builder.widget(&self.cstate);
191        builder.end(tag);
192    }
193    fn focus(&self) -> FocusFlag {
194        self.focus.clone()
195    }
196    fn area(&self) -> ratatui::layout::Rect {
197        self.area
198    }
199}
200
201#[async_trait(?Send)]
202impl Component for TextSearch {
203    fn render(&mut self, area: Layout, buf: &mut Buffer) {
204        self.render_w(area, buf);
205    }
206
207    fn register_action_tx(&mut self, action_tx: tokio::sync::mpsc::Sender<Action>) {
208        self.action_tx = Some(action_tx);
209    }
210    async fn handle_event(&mut self, event: Action) -> Result<(), AppError> {
211        match event {
212            Action::ChangeIssueScreen(screen) => {
213                self.screen = screen;
214            }
215            Action::RefreshIssueList => {
216                if self.screen != MainScreen::CreateIssue
217                    && self.screen != MainScreen::DetailsFullscreen
218                    && self.state != State::Loading
219                    && let Some(action_tx) = self.action_tx.clone()
220                {
221                    self.execute_search(action_tx).await;
222                }
223            }
224            Action::AppEvent(ref event) => {
225                if self.screen == MainScreen::CreateIssue
226                    || self.screen == MainScreen::DetailsFullscreen
227                {
228                    return Ok(());
229                }
230                if self.self_is_focused() {
231                    match event {
232                        ct_event!(keycode press Enter) => {
233                            if let Some(action_tx) = self.action_tx.clone() {
234                                self.execute_search(action_tx).await;
235                                return Ok(());
236                            }
237                        }
238                        _ => {}
239                    }
240                }
241                self.label_state.handle(event, Regular);
242                self.search_state.handle(event, Regular);
243                self.cstate.handle(event, Popup);
244            }
245            Action::FinishedLoading => {
246                self.state = State::Loaded;
247            }
248            Action::Tick => {
249                if self.state == State::Loading {
250                    self.loader_state.calc_next();
251                }
252            }
253            _ => {}
254        }
255        Ok(())
256    }
257    fn cursor(&self) -> Option<(u16, u16)> {
258        self.search_state
259            .screen_cursor()
260            .or(self.label_state.screen_cursor())
261            .or(self.cstate.screen_cursor())
262    }
263
264    fn is_animating(&self) -> bool {
265        self.screen != MainScreen::CreateIssue
266            && self.screen != MainScreen::DetailsFullscreen
267            && self.state == State::Loading
268    }
269
270    fn should_render(&self) -> bool {
271        self.screen != MainScreen::CreateIssue && self.screen != MainScreen::DetailsFullscreen
272    }
273    fn set_index(&mut self, index: usize) {
274        self.index = index;
275    }
276
277    fn capture_focus_event(&self, event: &crossterm::event::Event) -> bool {
278        self.self_is_focused()
279            && !matches!(
280                event,
281                ct_event!(keycode press Tab) | ct_event!(keycode press BackTab)
282            )
283    }
284
285    fn set_global_help(&self) {
286        if let Some(action_tx) = &self.action_tx {
287            let _ = action_tx.try_send(Action::SetHelp(HELP));
288        }
289    }
290}