mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-25 06:00:34 +00:00
dc8d0587ec
Fixes https://github.com/ratatui-org/ratatui/issues/1250 Adds support for selecting a column and cell in `TableState`. The selected column, and cells style can be set by `Table::column_highlight_style` and `Table::cell_highlight_style` respectively. The table example has also been updated to display the new functionality: https://github.com/user-attachments/assets/e5fd2858-4931-4ce1-a2f6-a5ea1eacbecc BREAKING CHANGE: The Serialized output of the state will now include the "selected_column" field. Software that manually parse the serialized the output (with anything other than the `Serialize` implementation on `TableState`) may have to be refactored if the "selected_column" field is not accounted for. This does not affect users who rely on the `Deserialize`, or `Serialize` implementation on the state. BREAKING CHANGE: The `Table::highlight_style` is now deprecated in favor of `Table::row_highlight_style`. --------- Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
258 lines
8.9 KiB
Rust
258 lines
8.9 KiB
Rust
//! # [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. You will need an
|
|
//! environment variable named `GITHUB_TOKEN` with a valid GitHub personal access token. The token
|
|
//! does not need any special permissions.
|
|
//!
|
|
//! <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::{eyre::Context, Result, Section};
|
|
use futures::StreamExt;
|
|
use octocrab::{
|
|
params::{pulls::Sort, Direction},
|
|
OctocrabBuilder, 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,
|
|
};
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
color_eyre::install()?;
|
|
init_octocrab()?;
|
|
let terminal = ratatui::init();
|
|
let app_result = App::default().run(terminal).await;
|
|
ratatui::restore();
|
|
app_result
|
|
}
|
|
|
|
fn init_octocrab() -> Result<()> {
|
|
let token = std::env::var("GITHUB_TOKEN")
|
|
.wrap_err("The GITHUB_TOKEN environment variable was not found")
|
|
.suggestion(
|
|
"Go to https://github.com/settings/tokens/new to create a token, and re-run:
|
|
GITHUB_TOKEN=ghp_... cargo run --example async --features crossterm",
|
|
)?;
|
|
let crab = OctocrabBuilder::new().personal_token(token).build()?;
|
|
octocrab::initialise(crab);
|
|
Ok(())
|
|
}
|
|
|
|
#[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])
|
|
}
|
|
}
|