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