1pub mod components;
2pub mod layout;
3pub mod macros;
4pub mod theme;
5pub mod utils;
6pub mod widgets;
7
8use crate::{
9 app::GITHUB_CLIENT,
10 define_cid_map,
11 errors::{AppError, Result},
12 ui::components::{
13 Component, DumbComponent,
14 help::HelpElementKind,
15 issue_conversation::IssueConversation,
16 issue_create::IssueCreate,
17 issue_detail::IssuePreview,
18 issue_list::{IssueList, MainScreen},
19 label_list::LabelList,
20 search_bar::TextSearch,
21 status_bar::StatusBar,
22 title_bar::TitleBar,
23 toast::{ToastBuilder, ToastEngineBuilder, ToastMessage},
24 },
25};
26use crossterm::{
27 event::{
28 DisableBracketedPaste, EnableBracketedPaste, EventStream, KeyEvent,
29 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
30 },
31 execute,
32};
33use futures::{StreamExt, future::FutureExt};
34use octocrab::{
35 Page,
36 models::{Label, issues::Issue, reactions::ReactionContent},
37};
38use rat_widget::{
39 event::{HandleEvent, Outcome, Regular},
40 focus::{Focus, FocusBuilder, FocusFlag},
41};
42use ratatui::{
43 crossterm,
44 prelude::*,
45 widgets::{Block, Clear, Padding, Paragraph, WidgetRef, Wrap},
46};
47use std::{
48 collections::HashMap,
49 fmt::Display,
50 io::stdout,
51 sync::{Arc, OnceLock},
52};
53use termprofile::{DetectorSettings, TermProfile};
54use tokio::{select, sync::mpsc::Sender};
55use tokio_util::sync::CancellationToken;
56use tracing::{error, info, instrument, trace};
57
58use anyhow::anyhow;
59
60use crate::ui::components::{
61 issue_conversation::{CommentView, IssueConversationSeed, TimelineEventView},
62 issue_detail::{IssuePreviewSeed, PrSummary},
63};
64
65const TICK_RATE: std::time::Duration = std::time::Duration::from_millis(100);
66pub static COLOR_PROFILE: OnceLock<TermProfile> = OnceLock::new();
67pub static CIDMAP: OnceLock<HashMap<u8, usize>> = OnceLock::new();
68const HELP_TEXT: &[HelpElementKind] = &[
69 crate::help_text!("Global Help"),
70 crate::help_text!(""),
71 crate::help_keybind!("1", "focus Search Bar"),
72 crate::help_keybind!("2", "focus Issue List"),
73 crate::help_keybind!("3", "focus Issue Conversation"),
74 crate::help_keybind!("4", "focus Label List"),
75 crate::help_keybind!("5", "focus Issue Create"),
76 crate::help_keybind!("q / Ctrl+C", "quit the application"),
77 crate::help_keybind!("? / Ctrl+H", "toggle help menu"),
78 crate::help_text!(""),
79 crate::help_text!(
80 "Navigate with the focus keys above. Components may have additional controls."
81 ),
82];
83
84pub async fn run(
85 AppState {
86 repo,
87 owner,
88 current_user,
89 }: AppState,
90) -> Result<(), AppError> {
91 if COLOR_PROFILE.get().is_none() {
92 COLOR_PROFILE
93 .set(TermProfile::detect(&stdout(), DetectorSettings::default()))
94 .map_err(|_| AppError::ErrorSettingGlobal("color profile"))?;
95 }
96 let mut terminal = ratatui::init();
97 setup_more_panic_hooks();
98 let (action_tx, action_rx) = tokio::sync::mpsc::channel(100);
99 let mut app = App::new(
100 action_tx,
101 action_rx,
102 AppState::new(repo, owner, current_user),
103 )
104 .await?;
105 let run_result = app.run(&mut terminal).await;
106 ratatui::restore();
107 finish_teardown()?;
108 run_result
109}
110
111struct App {
112 action_tx: tokio::sync::mpsc::Sender<Action>,
113 action_rx: tokio::sync::mpsc::Receiver<Action>,
114 toast_engine: Option<components::toast::ToastEngine<Action>>,
115 focus: Option<Focus>,
116 cancel_action: CancellationToken,
117 components: Vec<Box<dyn Component>>,
118 dumb_components: Vec<Box<dyn DumbComponent>>,
119 help: Option<&'static [HelpElementKind]>,
120 in_help: bool,
121 in_editor: bool,
122 current_screen: MainScreen,
123 last_focused: Option<FocusFlag>,
124 last_event_error: Option<String>,
125}
126
127#[derive(Debug, Default, Clone)]
128pub struct AppState {
129 repo: String,
130 owner: String,
131 current_user: String,
132}
133
134impl AppState {
135 pub fn new(repo: String, owner: String, current_user: String) -> Self {
136 Self {
137 repo,
138 owner,
139 current_user,
140 }
141 }
142}
143
144fn focus(state: &mut App) -> Result<&mut Focus, AppError> {
145 focus_noret(state);
146 state
147 .focus
148 .as_mut()
149 .ok_or_else(|| AppError::Other(anyhow!("focus state was not initialized")))
150}
151
152fn focus_noret(state: &mut App) {
153 let mut f = FocusBuilder::new(state.focus.take());
154 for component in state.components.iter() {
155 if component.should_render() {
156 f.widget(component.as_ref());
157 }
158 }
159 state.focus = Some(f.build());
160}
161
162impl App {
163 fn capture_error(&mut self, err: impl Display) {
164 let message = err.to_string();
165 error!(error = %message, "captured ui error");
166 self.last_event_error = Some(message);
167 }
168
169 pub async fn new(
170 action_tx: Sender<Action>,
171 action_rx: tokio::sync::mpsc::Receiver<Action>,
172 state: AppState,
173 ) -> Result<Self, AppError> {
174 let mut text_search = TextSearch::new(state.clone());
175 let status_bar = StatusBar::new(state.clone());
176 let mut label_list = LabelList::new(state.clone());
177 let issue_preview = IssuePreview::new(state.clone());
178 let mut issue_conversation = IssueConversation::new(state.clone());
179 let mut issue_create = IssueCreate::new(state.clone());
180 let issue_handler = GITHUB_CLIENT
181 .get()
182 .ok_or_else(|| AppError::Other(anyhow!("github client is not initialized")))?
183 .inner()
184 .issues(state.owner.clone(), state.repo.clone());
185 let mut issue_list = IssueList::new(
186 issue_handler,
187 state.owner.clone(),
188 state.repo.clone(),
189 action_tx.clone(),
190 )
191 .await;
192
193 let comps = define_cid_map!(
194 2 -> issue_list,
195 3 -> issue_conversation,
196 5 -> issue_create,
197 4 -> label_list,
198 1 -> text_search, )?;
200 Ok(Self {
201 focus: None,
202 toast_engine: None,
203 in_help: false,
204 in_editor: false,
205 current_screen: MainScreen::default(),
206 help: None,
207 action_tx,
208 action_rx,
209 last_focused: None,
210 last_event_error: None,
211 cancel_action: Default::default(),
212 components: comps,
213 dumb_components: vec![
214 Box::new(status_bar),
215 Box::new(issue_preview),
216 Box::new(TitleBar),
217 ],
218 })
219 }
220 pub async fn run(
221 &mut self,
222 terminal: &mut Terminal<CrosstermBackend<impl std::io::Write>>,
223 ) -> Result<(), AppError> {
224 let ctok = self.cancel_action.clone();
225 let action_tx = self.action_tx.clone();
226 for component in self.components.iter_mut() {
227 component.register_action_tx(action_tx.clone());
228 }
229
230 if let Err(err) = setup_terminal() {
231 self.capture_error(err);
232 }
233
234 tokio::spawn(async move {
235 let mut tick_interval = tokio::time::interval(TICK_RATE);
236 let mut event_stream = EventStream::new();
237
238 loop {
239 let event = select! {
240 _ = ctok.cancelled() => break,
241 _ = tick_interval.tick() => Action::Tick,
242 kevent = event_stream.next().fuse() => {
243 match kevent {
244 Some(Ok(kevent)) => Action::AppEvent(kevent),
245 Some(Err(..)) => Action::None,
246 None => break,
247 }
248 }
249 };
250 if action_tx.send(event).await.is_err() {
251 break;
252 }
253 }
254 Ok::<(), AppError>(())
255 });
256 focus_noret(self);
257 if let Some(ref mut focus) = self.focus {
258 if let Some(last) = self.components.last() {
259 focus.focus(&**last);
260 } else {
261 self.capture_error(anyhow!("no components available to focus"));
262 }
263 }
264 let ctok = self.cancel_action.clone();
265 let builder = ToastEngineBuilder::new(Rect::default()).action_tx(self.action_tx.clone());
266 self.toast_engine = Some(builder.build());
267 loop {
268 let action = self.action_rx.recv().await;
269 let mut should_draw_error_popup = false;
270 let mut full_redraw = false;
271 if let Some(ref action) = action {
272 if let Action::EditorModeChanged(enabled) = action {
273 self.in_editor = *enabled;
274 if *enabled {
275 continue;
276 }
277 full_redraw = true;
278 }
279 if self.in_editor && matches!(action, Action::Tick | Action::AppEvent(_)) {
280 continue;
281 }
282 for component in self.components.iter_mut() {
283 if let Err(err) = component.handle_event(action.clone()).await {
284 let message = err.to_string();
285 error!(error = %message, "captured ui error");
286 self.last_event_error = Some(message);
287 should_draw_error_popup = true;
288 }
289 if component.gained_focus() && self.last_focused != Some(component.focus()) {
290 self.last_focused = Some(component.focus());
291 component.set_global_help();
292 }
293 }
294 for component in self.dumb_components.iter_mut() {
295 if let Err(err) = component.handle_event(action.clone()).await {
296 let message = err.to_string();
297 error!(error = %message, "captured ui error");
298 self.last_event_error = Some(message);
299 should_draw_error_popup = true;
300 }
301 }
302 }
303 let should_draw = match &action {
304 Some(Action::Tick) => self.has_animated_components(),
305 Some(Action::None) => false,
306 Some(Action::Quit) | None => false,
307 _ => true,
308 };
309 match action {
310 Some(Action::None) | Some(Action::Tick) => {}
311 Some(Action::ToastAction(ref toast_action)) => match toast_action {
312 ToastMessage::Show {
313 message,
314 toast_type,
315 position,
316 } => {
317 if let Some(ref mut toast_engine) = self.toast_engine {
318 toast_engine.show_toast(
319 ToastBuilder::new(message.clone().into())
320 .toast_type(*toast_type)
321 .position(*position),
322 );
323 }
324 }
325 ToastMessage::Hide => {
326 if let Some(ref mut toast_engine) = self.toast_engine {
327 toast_engine.hide_toast();
328 }
329 }
330 },
331 Some(Action::ForceFocusChange) => match focus(self) {
332 Ok(focus) => {
333 let r = focus.next_force();
334 trace!(outcome = ?r, "Focus");
335 }
336 Err(err) => {
337 self.capture_error(err);
338 should_draw_error_popup = true;
339 }
340 },
341 Some(Action::ForceFocusChangeRev) => match focus(self) {
342 Ok(focus) => {
343 let r = focus.prev_force();
344 trace!(outcome = ?r, "Focus");
345 }
346 Err(err) => {
347 self.capture_error(err);
348 should_draw_error_popup = true;
349 }
350 },
351 Some(Action::AppEvent(ref event)) => {
352 info!(?event, "Received app event");
353 if let Err(err) = self.handle_event(event).await {
354 self.capture_error(err);
355 should_draw_error_popup = true;
356 }
357 }
358 Some(Action::SetHelp(help)) => {
359 self.help = Some(help);
360 }
361 Some(Action::EditorModeChanged(enabled)) => {
362 self.in_editor = enabled;
363 }
364 Some(Action::ChangeIssueScreen(screen)) => {
365 self.current_screen = screen;
366 focus_noret(self);
367 }
368 Some(Action::Quit) | None => {
369 ctok.cancel();
370
371 break;
372 }
373 _ => {}
374 }
375 if !self.in_editor
376 && (should_draw
377 || matches!(action, Some(Action::ForceRender))
378 || should_draw_error_popup)
379 {
380 if full_redraw && let Err(err) = terminal.clear() {
381 self.capture_error(err);
382 }
383 if let Err(err) = self.draw(terminal) {
384 self.capture_error(err);
385 }
386 }
387 if self.cancel_action.is_cancelled() {
388 break;
389 }
390 }
391
392 Ok(())
393 }
394 #[instrument(skip(self))]
395 async fn handle_event(&mut self, event: &crossterm::event::Event) -> Result<(), AppError> {
396 use crossterm::event::Event::Key;
397 use crossterm::event::KeyCode::*;
398 use rat_widget::event::ct_event;
399 trace!(?event, "Handling event");
400 if matches!(
401 event,
402 ct_event!(key press CONTROL-'c') | ct_event!(key press CONTROL-'q')
403 ) {
404 self.cancel_action.cancel();
405 return Ok(());
406 }
407 if self.last_event_error.is_some() {
408 if matches!(
409 event,
410 ct_event!(keycode press Esc) | ct_event!(keycode press Enter)
411 ) {
412 self.last_event_error = None;
413 }
414 return Ok(());
415 }
416 if matches!(event, ct_event!(key press CONTROL-'h')) {
417 self.in_help = !self.in_help;
418 self.help = Some(HELP_TEXT);
419 return Ok(());
420 }
421 if self.in_help && matches!(event, ct_event!(keycode press Esc)) {
422 self.in_help = false;
423 return Ok(());
424 }
425
426 let capture_focus = self
427 .components
428 .iter()
429 .any(|c| c.should_render() && c.capture_focus_event(event));
430 let focus = focus(self)?;
431 let outcome = focus.handle(event, Regular);
432 trace!(outcome = ?outcome, "Focus");
433 if let Outcome::Continue = outcome
434 && let Key(key) = event
435 && !capture_focus
436 {
437 self.handle_key(key).await?;
438 }
439 if let Key(key) = event {
440 match key.code {
441 Char(char)
442 if ('1'..='6').contains(&char)
443 && !self
444 .components
445 .iter()
446 .any(|c| c.should_render() && c.capture_focus_event(event)) =>
447 {
448 let index: u8 = char
450 .to_digit(10)
451 .ok_or_else(|| {
452 AppError::Other(anyhow!("failed to parse focus shortcut from key"))
453 })?
454 .try_into()
455 .map_err(|_| {
456 AppError::Other(anyhow!("focus shortcut is out of expected range"))
457 })?;
458 trace!("Focusing {}", index);
460 let cid_map = CIDMAP
461 .get()
462 .ok_or_else(|| AppError::ErrorSettingGlobal("component id map"))?;
463 let cid = cid_map.get(&index).ok_or_else(|| {
464 AppError::Other(anyhow!("component id {index} not found in focus map"))
465 })?;
466 let component = unsafe { self.components.get_unchecked(*cid) };
468
469 if let Some(f) = self.focus.as_mut() {
470 f.focus(component.as_ref());
471 }
472 }
473 _ => {}
474 }
475 }
476 Ok(())
477 }
478 async fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<(), AppError> {
479 use crossterm::event::KeyCode::*;
480 if matches!(key.code, Char('q'))
481 | matches!(
482 key,
483 KeyEvent {
484 code: Char('c' | 'q'),
485 modifiers: crossterm::event::KeyModifiers::CONTROL,
486 ..
487 }
488 )
489 {
490 self.cancel_action.cancel();
491 }
492 if matches!(key.code, Char('?')) {
493 self.in_help = !self.in_help;
494 }
495
496 Ok(())
497 }
498
499 fn has_animated_components(&self) -> bool {
500 self.components
501 .iter()
502 .any(|component| component.should_render() && component.is_animating())
503 }
504
505 fn draw(
506 &mut self,
507 terminal: &mut Terminal<CrosstermBackend<impl std::io::Write>>,
508 ) -> Result<(), AppError> {
509 terminal.draw(|f| {
510 let area = f.area();
511 let fullscreen = self.current_screen == MainScreen::DetailsFullscreen;
512 let layout = if fullscreen {
513 layout::Layout::fullscreen(area)
514 } else {
515 layout::Layout::new(area)
516 };
517 for component in self.components.iter() {
518 if component.should_render()
519 && let Some(p) = component.cursor()
520 {
521 f.set_cursor_position(p);
522 }
523 }
524 let buf = f.buffer_mut();
525
526 for component in self.components.iter_mut() {
527 if component.should_render() {
528 component.render(layout, buf);
529 }
530 }
531 if !fullscreen {
532 for component in self.dumb_components.iter_mut() {
533 component.render(layout, buf);
534 }
535 }
536 if self.in_help {
537 let help_text = self.help.unwrap_or(HELP_TEXT);
538 let help_component = components::help::HelpComponent::new(help_text)
539 .set_constraint(30)
540 .block(
541 Block::bordered()
542 .title("Help")
543 .padding(Padding::horizontal(2))
544 .border_type(ratatui::widgets::BorderType::Rounded),
545 );
546 help_component.render(area, buf);
547 }
548 if let Some(err) = self.last_event_error.as_ref() {
549 let popup_area = area.centered(Constraint::Percentage(60), Constraint::Length(5));
550 Clear.render(popup_area, buf);
551 let popup = Paragraph::new(err.as_str())
552 .wrap(Wrap { trim: false })
553 .block(
554 Block::bordered()
555 .title("Error")
556 .title_bottom("Esc/Enter: dismiss")
557 .padding(Padding::horizontal(1))
558 .border_type(ratatui::widgets::BorderType::Rounded),
559 );
560 popup.render(popup_area, buf);
561 }
562 if let Some(ref mut toast_engine) = self.toast_engine {
563 toast_engine.set_area(area);
564 toast_engine.render_ref(area, f.buffer_mut());
565 }
566 })?;
567 Ok(())
568 }
569}
570
571#[derive(Debug, Clone)]
572#[non_exhaustive]
573pub enum Action {
574 None,
575 Tick,
576 Quit,
577 AppEvent(crossterm::event::Event),
578 RefreshIssueList,
579 NewPage(Arc<Page<Issue>>, MergeStrategy),
580 ForceRender,
581 SelectedIssue {
582 number: u64,
583 labels: Vec<Label>,
584 },
585 SelectedIssuePreview {
586 seed: IssuePreviewSeed,
587 },
588 IssuePreviewLoaded {
589 number: u64,
590 open_prs: Vec<PrSummary>,
591 },
592 IssuePreviewError {
593 number: u64,
594 message: String,
595 },
596 EnterIssueDetails {
597 seed: IssueConversationSeed,
598 },
599 IssueCommentsLoaded {
600 number: u64,
601 comments: Vec<CommentView>,
602 },
603 IssueTimelineLoaded {
604 number: u64,
605 events: Vec<TimelineEventView>,
606 },
607 IssueTimelineError {
608 number: u64,
609 message: String,
610 },
611 IssueReactionsLoaded {
612 reactions: HashMap<u64, Vec<(ReactionContent, u64)>>,
613 own_reactions: HashMap<u64, Vec<ReactionContent>>,
614 },
615 IssueReactionEditError {
616 comment_id: u64,
617 message: String,
618 },
619 IssueCommentPosted {
620 number: u64,
621 comment: CommentView,
622 },
623 IssueCommentsError {
624 number: u64,
625 message: String,
626 },
627 IssueCommentPostError {
628 number: u64,
629 message: String,
630 },
631 IssueCommentEditFinished {
632 issue_number: u64,
633 comment_id: u64,
634 result: std::result::Result<String, String>,
635 },
636 IssueCommentPatched {
637 issue_number: u64,
638 comment: CommentView,
639 },
640 EnterIssueCreate,
641 IssueCreateSuccess {
642 issue: Box<Issue>,
643 },
644 IssueCreateError {
645 message: String,
646 },
647 IssueCloseSuccess {
648 issue: Box<Issue>,
649 },
650 IssueCloseError {
651 number: u64,
652 message: String,
653 },
654 IssueLabelsUpdated {
655 number: u64,
656 labels: Vec<Label>,
657 },
658 LabelMissing {
659 name: String,
660 },
661 LabelEditError {
662 message: String,
663 },
664 LabelSearchPageAppend {
665 request_id: u64,
666 items: Vec<Label>,
667 scanned: u32,
668 matched: u32,
669 },
670 LabelSearchFinished {
671 request_id: u64,
672 scanned: u32,
673 matched: u32,
674 },
675 LabelSearchError {
676 request_id: u64,
677 message: String,
678 },
679 ChangeIssueScreen(MainScreen),
680 FinishedLoading,
681 ForceFocusChange,
682 ForceFocusChangeRev,
683 SetHelp(&'static [HelpElementKind]),
684 EditorModeChanged(bool),
685 ToastAction(crate::ui::components::toast::ToastMessage),
686}
687
688impl From<crate::ui::components::toast::ToastMessage> for Action {
689 fn from(value: crate::ui::components::toast::ToastMessage) -> Self {
690 Self::ToastAction(value)
691 }
692}
693
694#[derive(Debug, Clone)]
695pub enum MergeStrategy {
696 Append,
697 Replace,
698}
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum CloseIssueReason {
702 Completed,
703 NotPlanned,
704 Duplicate,
705}
706
707impl CloseIssueReason {
708 pub const ALL: [Self; 3] = [Self::Completed, Self::NotPlanned, Self::Duplicate];
709
710 pub const fn label(self) -> &'static str {
711 match self {
712 Self::Completed => "Completed",
713 Self::NotPlanned => "Not planned",
714 Self::Duplicate => "Duplicate",
715 }
716 }
717
718 pub const fn to_octocrab(self) -> octocrab::models::issues::IssueStateReason {
719 match self {
720 Self::Completed => octocrab::models::issues::IssueStateReason::Completed,
721 Self::NotPlanned => octocrab::models::issues::IssueStateReason::NotPlanned,
722 Self::Duplicate => octocrab::models::issues::IssueStateReason::Duplicate,
723 }
724 }
725}
726
727fn finish_teardown() -> Result<()> {
728 let mut stdout = stdout();
729 execute!(stdout, PopKeyboardEnhancementFlags)?;
730 execute!(stdout, DisableBracketedPaste)?;
731
732 Ok(())
733}
734
735fn setup_terminal() -> Result<()> {
736 let mut stdout = stdout();
737 execute!(
738 stdout,
739 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
740 )?;
741 execute!(
742 stdout,
743 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES)
744 )?;
745 execute!(
746 stdout,
747 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
748 )?;
749 execute!(
750 stdout,
751 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
752 )?;
753 execute!(stdout, EnableBracketedPaste)?;
754
755 Ok(())
756}
757
758fn setup_more_panic_hooks() {
759 let hook = std::panic::take_hook();
760 std::panic::set_hook(Box::new(move |info| {
761 tracing::error!(panic_info = ?info, "Panic occurred");
763 let _ = finish_teardown();
764 hook(info);
765 }));
766}
767
768fn toast_action(
769 message: impl Into<String>,
770 toast_type: crate::ui::components::toast::ToastType,
771) -> Action {
772 use crate::ui::components::toast::ToastPosition::TopRight;
773 Action::ToastAction(crate::ui::components::toast::ToastMessage::Show {
774 message: message.into(),
775 toast_type,
776 position: TopRight,
777 })
778}