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