Skip to main content

gitv_tui/ui/
mod.rs

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