use ratatui::{
DefaultTerminal, Frame,
crossterm::{
self,
event::{Event, KeyCode, KeyModifiers},
},
layout::{Constraint, Layout},
style::{Color, Style},
widgets::{Paragraph, Row, Table, Wrap},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct Story {
title: String,
url: Option<String>,
score: u32,
by: String,
}
struct State {
stories: Vec<Story>,
selected: usize,
show_help: bool,
}
fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
let stories: Vec<Story> = fetch_hn();
let mut state = State {
stories,
selected: 0,
show_help: true,
};
ratatui::run(|terminal| app(terminal, &mut state))?;
Ok(())
}
fn app(terminal: &mut DefaultTerminal, state: &mut State) -> std::io::Result<()> {
loop {
terminal.draw(|frame| render(frame, state))?;
match crossterm::event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('q')
|| ((key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d'))
&& key.modifiers.contains(KeyModifiers::CONTROL))
{
break Ok(());
}
if key.code == KeyCode::Char('k') {
if state.selected != 0 {
state.selected -= 1;
}
}
if key.code == KeyCode::Char('j') {
if state.stories.get(state.selected + 1).is_some() {
state.selected += 1;
}
}
if key.code == KeyCode::Char('o') && state.stories[state.selected].url.is_some() {
open::that(state.stories[state.selected].url.as_deref().unwrap())?;
}
if key.code == KeyCode::Char('?') {
state.show_help = !state.show_help;
}
}
_ => {}
}
}
}
fn render(frame: &mut Frame, state: &mut State) {
let outer_layout = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints(vec![Constraint::Percentage(80), Constraint::Percentage(20)])
.split(frame.area());
if state.show_help {
let help_header = Row::new(["Key", "Action"])
.style(Style::new().bold())
.bottom_margin(1);
let help_rows = [
Row::new(["?", "Show/Hide Help"]),
Row::new(["j", "Scroll down"]),
Row::new(["k", "Scroll up"]),
Row::new(["o", "Open URL"]),
Row::new(["q", "Quit"]),
];
let widths = [Constraint::Percentage(20), Constraint::Percentage(80)];
let help_table = Table::new(help_rows, widths)
.header(help_header)
.column_spacing(1)
.style(Color::Cyan);
frame.render_widget(help_table, outer_layout[1]);
}
let inner_layout = Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints(vec![
Constraint::Percentage(10),
Constraint::Percentage(5),
Constraint::Percentage(20),
Constraint::Percentage(80),
])
.split(outer_layout[0]);
let story = &state.stories[state.selected];
let story_p = Paragraph::new(format!(
"{}, by {}. (HN Score: {})",
story.title, story.by, story.score
))
.wrap(Wrap { trim: false });
let link_p = Paragraph::new(format!("{}", story.url.as_deref().unwrap_or("(no url)")))
.wrap(Wrap { trim: false });
frame.render_widget(story_p, inner_layout[1]);
frame.render_widget(link_p, inner_layout[2]);
}
fn fetch_hn() -> Vec<Story> {
let client = reqwest::blocking::Client::new();
println!("Fetching Hacker News stories... please wait :)");
let ids: Vec<u64> = client
.get("https://hacker-news.firebaseio.com/v0/topstories.json")
.send()
.expect("Failed to fetch from hacker news")
.json()
.expect("Failed to parse story ids");
let mut stories: Vec<Story> = vec![];
for (_, id) in ids.iter().take(20).enumerate() {
let url = format!("https://hacker-news.firebaseio.com/v0/item/{id}.json");
let story: Story = client
.get(&url)
.send()
.expect("Failed to fetch story")
.json()
.expect("Failed to parse story");
stories.push(story);
}
stories
}