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