Skip to main content

gitv_tui/ui/
mod.rs

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