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