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