1use anyhow::{Context, Result};
2use crossterm::event::{Event as CrosstermEvent, EventStream};
3use futures::StreamExt;
4use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend};
5use std::io;
6use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
7
8mod events;
9mod render;
10mod state;
11mod ui;
12mod utils;
13
14pub use state::{
15 RatatuiCommand, RatatuiEvent, RatatuiHandle, RatatuiMessageKind, RatatuiSegment,
16 RatatuiSession, RatatuiTextStyle, RatatuiTheme,
17};
18pub use utils::{convert_style, parse_tui_color, theme_from_styles};
19
20use state::{RatatuiLoop, TerminalGuard, TerminalSurface};
21use utils::create_ticker;
22
23pub fn spawn_session(theme: RatatuiTheme, placeholder: Option<String>) -> Result<RatatuiSession> {
24 let (command_tx, command_rx) = mpsc::unbounded_channel();
25 let (event_tx, event_rx) = mpsc::unbounded_channel();
26
27 tokio::spawn(async move {
28 if let Err(err) = run_ratatui(command_rx, event_tx, theme, placeholder).await {
29 tracing::error!(error = ?err, "ratatui session terminated unexpectedly");
30 }
31 });
32
33 Ok(RatatuiSession {
34 handle: RatatuiHandle { sender: command_tx },
35 events: event_rx,
36 })
37}
38
39async fn run_ratatui(
40 commands: UnboundedReceiver<RatatuiCommand>,
41 events: UnboundedSender<RatatuiEvent>,
42 theme: RatatuiTheme,
43 placeholder: Option<String>,
44) -> Result<()> {
45 let surface = TerminalSurface::detect().context("failed to resolve terminal surface")?;
46 let mut stdout = io::stdout();
47 let backend = CrosstermBackend::new(&mut stdout);
48 let mut terminal = match surface {
49 TerminalSurface::Alternate => {
50 Terminal::new(backend).context("failed to initialize ratatui terminal")?
51 }
52 TerminalSurface::Inline { rows } => Terminal::with_options(
53 backend,
54 TerminalOptions {
55 viewport: Viewport::Inline(rows),
56 },
57 )
58 .context("failed to initialize ratatui terminal")?,
59 };
60 let _guard =
61 TerminalGuard::activate(surface).context("failed to configure terminal for ratatui")?;
62 terminal
63 .clear()
64 .context("failed to clear terminal for ratatui")?;
65
66 let mut app = RatatuiLoop::new(theme, placeholder);
67 let mut command_rx = commands;
68 let mut event_stream = EventStream::new();
69 let mut redraw = true;
70 let mut ticker = create_ticker();
71
72 loop {
73 if app.drain_command_queue(&mut command_rx) {
74 redraw = true;
75 }
76
77 if redraw {
78 terminal
79 .draw(|frame| app.draw(frame))
80 .context("failed to draw ratatui frame")?;
81 redraw = false;
82 }
83
84 if app.should_exit() {
85 break;
86 }
87
88 tokio::select! {
89 biased;
90 cmd = command_rx.recv() => {
91 if let Some(command) = cmd {
92 if app.handle_command(command) {
93 redraw = true;
94 }
95 } else {
96 app.set_should_exit();
97 }
98 }
99 event = event_stream.next() => {
100 match event {
101 Some(Ok(evt)) => {
102 if matches!(evt, CrosstermEvent::Resize(_, _)) {
103 terminal
104 .autoresize()
105 .context("failed to autoresize terminal viewport")?;
106 }
107 if app.handle_event(evt, &events)? {
108 redraw = true;
109 }
110 }
111 Some(Err(_)) => {
112 redraw = true;
113 }
114 None => {}
115 }
116 }
117 _ = ticker.tick() => {
118 if app.needs_tick() {
119 redraw = true;
120 }
121 }
122 }
123
124 if app.should_exit() {
125 break;
126 }
127 }
128
129 terminal.show_cursor().ok();
130 terminal
131 .clear()
132 .context("failed to clear terminal after ratatui session")?;
133
134 Ok(())
135}