Skip to main content

gitv_tui/ui/
mod.rs

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, // this needs to be the last one
199        )?;
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                    //SAFETY: char is in range
449                    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                    //SAFETY: cid is always in map, and map is static
459                    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                    //SAFETY: cid is in map, and map is static
467                    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        // we want to log the panic with tracing, but also preserve the default panic behavior of printing to stderr and aborting
762        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}