1use 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#[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 fn run(&self) {
162 let this = self.clone(); tokio::spawn(this.fetch_pulls());
164 }
165
166 async fn fetch_pulls(self) {
167 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 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 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}