chore(examples): move async example to apps (#1503)

Move async example to examples/apps/async as full project.
Simplify a little by removing the need for the github api token.

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
This commit is contained in:
Josh McKinney
2024-12-09 07:30:37 -08:00
committed by GitHub
parent fab532171d
commit b5f7e44183
7 changed files with 78 additions and 29 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "async-github"
version = "0.1.0"
authors.workspace = true
documentation.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
readme.workspace = true
license.workspace = true
exclude.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
color-eyre = "0.6.3"
crossterm = { workspace = true, features = ["event-stream"] }
octocrab = "0.42.0"
ratatui.workspace = true
tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros"] }
tokio-stream = "0.1.16"

View File

@@ -0,0 +1,9 @@
# Async GitHub demo
This example demonstrates how to use Ratatui with widgets that fetch data from GitHub API asynchronously.
To run this demo:
```shell
cargo run -p async-github
```

View File

@@ -0,0 +1,243 @@
//! # [Ratatui] Async example
//!
//! This example demonstrates how to use Ratatui with widgets that fetch data asynchronously. It
//! uses the `octocrab` crate to fetch a list of pull requests from the GitHub API.
//!
//! <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token>
//! <https://github.com/settings/tokens/new> to create a new token (select classic, and no scopes)
//!
//! This example does not cover message passing between threads, it only demonstrates how to manage
//! shared state between the main thread and a background task, which acts mostly as a one-shot
//! fetcher. For more complex scenarios, you may need to use channels or other synchronization
//! primitives.
//!
//! A simple app might have multiple widgets that fetch data from different sources, and each widget
//! would have its own background task to fetch the data. The main thread would then render the
//! widgets with the latest data.
//!
//! The latest version of this example is available in the [examples] folder in the repository.
//!
//! Please note that the examples are designed to be run against the `main` branch of the Github
//! repository. This means that you may not be able to compile with the latest release version on
//! crates.io, or the one that you have installed locally.
//!
//! See the [examples readme] for more information on finding examples that match the version of the
//! library you are using.
//!
//! [Ratatui]: https://github.com/ratatui/ratatui
//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
use std::{
sync::{Arc, RwLock},
time::Duration,
};
use color_eyre::Result;
use octocrab::{
params::{pulls::Sort, Direction},
Page,
};
use ratatui::{
buffer::Buffer,
crossterm::event::{Event, EventStream, KeyCode, KeyEventKind},
layout::{Constraint, Layout, Rect},
style::{Style, Stylize},
text::Line,
widgets::{Block, HighlightSpacing, Row, StatefulWidget, Table, TableState, Widget},
DefaultTerminal, Frame,
};
use tokio_stream::StreamExt;
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let terminal = ratatui::init();
let app_result = App::default().run(terminal).await;
ratatui::restore();
app_result
}
#[derive(Debug, Default)]
struct App {
should_quit: bool,
pull_requests: PullRequestListWidget,
}
impl App {
const FRAMES_PER_SECOND: f32 = 60.0;
pub async fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
self.pull_requests.run();
let period = Duration::from_secs_f32(1.0 / Self::FRAMES_PER_SECOND);
let mut interval = tokio::time::interval(period);
let mut events = EventStream::new();
while !self.should_quit {
tokio::select! {
_ = interval.tick() => { terminal.draw(|frame| self.draw(frame))?; },
Some(Ok(event)) = events.next() => self.handle_event(&event),
}
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
let vertical = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]);
let [title_area, body_area] = vertical.areas(frame.area());
let title = Line::from("Ratatui async example").centered().bold();
frame.render_widget(title, title_area);
frame.render_widget(&self.pull_requests, body_area);
}
fn handle_event(&mut self, event: &Event) {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => self.pull_requests.scroll_down(),
KeyCode::Char('k') | KeyCode::Up => self.pull_requests.scroll_up(),
_ => {}
}
}
}
}
}
/// A widget that displays a list of pull requests.
///
/// This is an async widget that fetches the list of pull requests from the GitHub API. It contains
/// an inner `Arc<RwLock<PullRequestListState>>` that holds the state of the widget. Cloning the
/// widget will clone the Arc, so you can pass it around to other threads, and this is used to spawn
/// a background task to fetch the pull requests.
#[derive(Debug, Clone, Default)]
struct PullRequestListWidget {
state: Arc<RwLock<PullRequestListState>>,
}
#[derive(Debug, Default)]
struct PullRequestListState {
pull_requests: Vec<PullRequest>,
loading_state: LoadingState,
table_state: TableState,
}
#[derive(Debug, Clone)]
struct PullRequest {
id: String,
title: String,
url: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
enum LoadingState {
#[default]
Idle,
Loading,
Loaded,
Error(String),
}
impl PullRequestListWidget {
/// Start fetching the pull requests in the background.
///
/// This method spawns a background task that fetches the pull requests from the GitHub API.
/// The result of the fetch is then passed to the `on_load` or `on_err` methods.
fn run(&self) {
let this = self.clone(); // clone the widget to pass to the background task
tokio::spawn(this.fetch_pulls());
}
async fn fetch_pulls(self) {
// this runs once, but you could also run this in a loop, using a channel that accepts
// messages to refresh on demand, or with an interval timer to refresh every N seconds
self.set_loading_state(LoadingState::Loading);
match octocrab::instance()
.pulls("ratatui", "ratatui")
.list()
.sort(Sort::Updated)
.direction(Direction::Descending)
.send()
.await
{
Ok(page) => self.on_load(&page),
Err(err) => self.on_err(&err),
}
}
fn on_load(&self, page: &Page<OctoPullRequest>) {
let prs = page.items.iter().map(Into::into);
let mut state = self.state.write().unwrap();
state.loading_state = LoadingState::Loaded;
state.pull_requests.extend(prs);
if !state.pull_requests.is_empty() {
state.table_state.select(Some(0));
}
}
fn on_err(&self, err: &octocrab::Error) {
self.set_loading_state(LoadingState::Error(err.to_string()));
}
fn set_loading_state(&self, state: LoadingState) {
self.state.write().unwrap().loading_state = state;
}
fn scroll_down(&self) {
self.state.write().unwrap().table_state.scroll_down_by(1);
}
fn scroll_up(&self) {
self.state.write().unwrap().table_state.scroll_up_by(1);
}
}
type OctoPullRequest = octocrab::models::pulls::PullRequest;
impl From<&OctoPullRequest> for PullRequest {
fn from(pr: &OctoPullRequest) -> Self {
Self {
id: pr.number.to_string(),
title: pr.title.as_ref().unwrap().to_string(),
url: pr
.html_url
.as_ref()
.map(ToString::to_string)
.unwrap_or_default(),
}
}
}
impl Widget for &PullRequestListWidget {
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = self.state.write().unwrap();
// a block with a right aligned title with the loading state on the right
let loading_state = Line::from(format!("{:?}", state.loading_state)).right_aligned();
let block = Block::bordered()
.title("Pull Requests")
.title(loading_state)
.title_bottom("j/k to scroll, q to quit");
// a table with the list of pull requests
let rows = state.pull_requests.iter();
let widths = [
Constraint::Length(5),
Constraint::Fill(1),
Constraint::Max(49),
];
let table = Table::new(rows, widths)
.block(block)
.highlight_spacing(HighlightSpacing::Always)
.highlight_symbol(">>")
.row_highlight_style(Style::new().on_blue());
StatefulWidget::render(table, area, buf, &mut state.table_state);
}
}
impl From<&PullRequest> for Row<'_> {
fn from(pr: &PullRequest) -> Self {
let pr = pr.clone();
Row::new(vec![pr.id, pr.title, pr.url])
}
}