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