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, )?;
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 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 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 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 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}