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