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