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 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}