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