async/
async.rs

1//! # [Ratatui] Async example
2//!
3//! This example demonstrates how to use Ratatui with widgets that fetch data asynchronously. It
4//! uses the `octocrab` crate to fetch a list of pull requests from the GitHub API. You will need an
5//! environment variable named `GITHUB_TOKEN` with a valid GitHub personal access token. The token
6//! does not need any special permissions.
7//!
8//! <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token>
9//! <https://github.com/settings/tokens/new> to create a new token (select classic, and no scopes)
10//!
11//! This example does not cover message passing between threads, it only demonstrates how to manage
12//! shared state between the main thread and a background task, which acts mostly as a one-shot
13//! fetcher. For more complex scenarios, you may need to use channels or other synchronization
14//! primitives.
15//!
16//! A simple app might have multiple widgets that fetch data from different sources, and each widget
17//! would have its own background task to fetch the data. The main thread would then render the
18//! widgets with the latest data.
19//!
20//! The latest version of this example is available in the [examples] folder in the repository.
21//!
22//! Please note that the examples are designed to be run against the `main` branch of the Github
23//! repository. This means that you may not be able to compile with the latest release version on
24//! crates.io, or the one that you have installed locally.
25//!
26//! See the [examples readme] for more information on finding examples that match the version of the
27//! library you are using.
28//!
29//! [Ratatui]: https://github.com/ratatui/ratatui
30//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
31//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
32use std::{
33    sync::{Arc, RwLock},
34    time::Duration,
35};
36
37use color_eyre::{eyre::Context, Result, Section};
38use futures::StreamExt;
39use octocrab::{
40    params::{pulls::Sort, Direction},
41    OctocrabBuilder, Page,
42};
43use ratatui::{
44    buffer::Buffer,
45    crossterm::event::{Event, EventStream, KeyCode, KeyEventKind},
46    layout::{Constraint, Layout, Rect},
47    style::{Style, Stylize},
48    text::Line,
49    widgets::{Block, HighlightSpacing, Row, StatefulWidget, Table, TableState, Widget},
50    DefaultTerminal, Frame,
51};
52
53#[tokio::main]
54async fn main() -> Result<()> {
55    color_eyre::install()?;
56    init_octocrab()?;
57    let terminal = ratatui::init();
58    let app_result = App::default().run(terminal).await;
59    ratatui::restore();
60    app_result
61}
62
63fn init_octocrab() -> Result<()> {
64    let token = std::env::var("GITHUB_TOKEN")
65        .wrap_err("The GITHUB_TOKEN environment variable was not found")
66        .suggestion(
67            "Go to https://github.com/settings/tokens/new to create a token, and re-run:
68            GITHUB_TOKEN=ghp_... cargo run --example async --features crossterm",
69        )?;
70    let crab = OctocrabBuilder::new().personal_token(token).build()?;
71    octocrab::initialise(crab);
72    Ok(())
73}
74
75#[derive(Debug, Default)]
76struct App {
77    should_quit: bool,
78    pull_requests: PullRequestListWidget,
79}
80
81impl App {
82    const FRAMES_PER_SECOND: f32 = 60.0;
83
84    pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
85        self.pull_requests.run();
86
87        let period = Duration::from_secs_f32(1.0 / Self::FRAMES_PER_SECOND);
88        let mut interval = tokio::time::interval(period);
89        let mut events = EventStream::new();
90
91        while !self.should_quit {
92            tokio::select! {
93                _ = interval.tick() => { terminal.draw(|frame| self.draw(frame))?; },
94                Some(Ok(event)) = events.next() => self.handle_event(&event),
95            }
96        }
97        Ok(())
98    }
99
100    fn draw(&self, frame: &mut Frame) {
101        let vertical = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]);
102        let [title_area, body_area] = vertical.areas(frame.area());
103        let title = Line::from("Ratatui async example").centered().bold();
104        frame.render_widget(title, title_area);
105        frame.render_widget(&self.pull_requests, body_area);
106    }
107
108    fn handle_event(&mut self, event: &Event) {
109        if let Event::Key(key) = event {
110            if key.kind == KeyEventKind::Press {
111                match key.code {
112                    KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
113                    KeyCode::Char('j') | KeyCode::Down => self.pull_requests.scroll_down(),
114                    KeyCode::Char('k') | KeyCode::Up => self.pull_requests.scroll_up(),
115                    _ => {}
116                }
117            }
118        }
119    }
120}
121
122/// A widget that displays a list of pull requests.
123///
124/// This is an async widget that fetches the list of pull requests from the GitHub API. It contains
125/// an inner `Arc<RwLock<PullRequestListState>>` that holds the state of the widget. Cloning the
126/// widget will clone the Arc, so you can pass it around to other threads, and this is used to spawn
127/// a background task to fetch the pull requests.
128#[derive(Debug, Clone, Default)]
129struct PullRequestListWidget {
130    state: Arc<RwLock<PullRequestListState>>,
131}
132
133#[derive(Debug, Default)]
134struct PullRequestListState {
135    pull_requests: Vec<PullRequest>,
136    loading_state: LoadingState,
137    table_state: TableState,
138}
139
140#[derive(Debug, Clone)]
141struct PullRequest {
142    id: String,
143    title: String,
144    url: String,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148enum LoadingState {
149    #[default]
150    Idle,
151    Loading,
152    Loaded,
153    Error(String),
154}
155
156impl PullRequestListWidget {
157    /// Start fetching the pull requests in the background.
158    ///
159    /// This method spawns a background task that fetches the pull requests from the GitHub API.
160    /// The result of the fetch is then passed to the `on_load` or `on_err` methods.
161    fn run(&self) {
162        let this = self.clone(); // clone the widget to pass to the background task
163        tokio::spawn(this.fetch_pulls());
164    }
165
166    async fn fetch_pulls(self) {
167        // this runs once, but you could also run this in a loop, using a channel that accepts
168        // messages to refresh on demand, or with an interval timer to refresh every N seconds
169        self.set_loading_state(LoadingState::Loading);
170        match octocrab::instance()
171            .pulls("ratatui", "ratatui")
172            .list()
173            .sort(Sort::Updated)
174            .direction(Direction::Descending)
175            .send()
176            .await
177        {
178            Ok(page) => self.on_load(&page),
179            Err(err) => self.on_err(&err),
180        }
181    }
182    fn on_load(&self, page: &Page<OctoPullRequest>) {
183        let prs = page.items.iter().map(Into::into);
184        let mut state = self.state.write().unwrap();
185        state.loading_state = LoadingState::Loaded;
186        state.pull_requests.extend(prs);
187        if !state.pull_requests.is_empty() {
188            state.table_state.select(Some(0));
189        }
190    }
191
192    fn on_err(&self, err: &octocrab::Error) {
193        self.set_loading_state(LoadingState::Error(err.to_string()));
194    }
195
196    fn set_loading_state(&self, state: LoadingState) {
197        self.state.write().unwrap().loading_state = state;
198    }
199
200    fn scroll_down(&self) {
201        self.state.write().unwrap().table_state.scroll_down_by(1);
202    }
203
204    fn scroll_up(&self) {
205        self.state.write().unwrap().table_state.scroll_up_by(1);
206    }
207}
208
209type OctoPullRequest = octocrab::models::pulls::PullRequest;
210
211impl From<&OctoPullRequest> for PullRequest {
212    fn from(pr: &OctoPullRequest) -> Self {
213        Self {
214            id: pr.number.to_string(),
215            title: pr.title.as_ref().unwrap().to_string(),
216            url: pr
217                .html_url
218                .as_ref()
219                .map(ToString::to_string)
220                .unwrap_or_default(),
221        }
222    }
223}
224
225impl Widget for &PullRequestListWidget {
226    fn render(self, area: Rect, buf: &mut Buffer) {
227        let mut state = self.state.write().unwrap();
228
229        // a block with a right aligned title with the loading state on the right
230        let loading_state = Line::from(format!("{:?}", state.loading_state)).right_aligned();
231        let block = Block::bordered()
232            .title("Pull Requests")
233            .title(loading_state)
234            .title_bottom("j/k to scroll, q to quit");
235
236        // a table with the list of pull requests
237        let rows = state.pull_requests.iter();
238        let widths = [
239            Constraint::Length(5),
240            Constraint::Fill(1),
241            Constraint::Max(49),
242        ];
243        let table = Table::new(rows, widths)
244            .block(block)
245            .highlight_spacing(HighlightSpacing::Always)
246            .highlight_symbol(">>")
247            .row_highlight_style(Style::new().on_blue());
248
249        StatefulWidget::render(table, area, buf, &mut state.table_state);
250    }
251}
252
253impl From<&PullRequest> for Row<'_> {
254    fn from(pr: &PullRequest) -> Self {
255        let pr = pr.clone();
256        Row::new(vec![pr.id, pr.title, pr.url])
257    }
258}