chore: refinements
3dc1c298
2 file(s) · +83 −13
| 10 | 10 | color-eyre = "0.6.3" |
|
| 11 | 11 | crossterm = "0.29.0" |
|
| 12 | 12 | feedparser-rs = "0.5.3" |
|
| 13 | + | open = "5" |
|
| 14 | + | chrono = "0.4" |
|
| 13 | 15 | ratatui = "0.30.0" |
|
| 14 | 16 | ||
| 15 | 17 | # Read the optimization guideline for more details: https://ratatui.rs/recipes/apps/release-your-app/#optimizations |
| 1 | + | use chrono::NaiveDateTime; |
|
| 2 | + | use crossterm::event::{KeyCode, KeyEvent}; |
|
| 1 | 3 | use feedparser_rs::{ParsedFeed, parse_url}; |
|
| 2 | 4 | use ratatui::{ |
|
| 3 | 5 | DefaultTerminal, Frame, |
|
| 6 | + | layout::{Constraint, Direction, Layout}, |
|
| 4 | 7 | style::{Color, Modifier, Style}, |
|
| 5 | 8 | text::{Line, Span, Text}, |
|
| 6 | - | widgets::List, |
|
| 7 | - | widgets::ListItem, |
|
| 9 | + | widgets::{Block, List, ListItem, ListState, Padding}, |
|
| 8 | 10 | }; |
|
| 9 | 11 | ||
| 10 | 12 | fn main() -> color_eyre::Result<()> { |
|
| 11 | 13 | color_eyre::install()?; |
|
| 12 | - | let feed = parse_url("https://feeds.stevedylan.dev/feed.xml", None, None, None)?; |
|
| 14 | + | let url = std::env::args().nth(1).ok_or_else(|| { |
|
| 15 | + | color_eyre::eyre::eyre!("Usage: bullet <feed-url>") |
|
| 16 | + | })?; |
|
| 17 | + | let feed = parse_url(&url, None, None, None)?; |
|
| 13 | 18 | ratatui::run(|t| app(t, &feed))?; |
|
| 14 | 19 | Ok(()) |
|
| 15 | 20 | } |
|
| 16 | 21 | ||
| 17 | 22 | fn app(terminal: &mut DefaultTerminal, feed: &ParsedFeed) -> std::io::Result<()> { |
|
| 23 | + | let mut state = ListState::default(); |
|
| 24 | + | state.select(Some(0)); |
|
| 25 | + | ||
| 18 | 26 | loop { |
|
| 19 | - | terminal.draw(|f| render(f, feed))?; |
|
| 20 | - | if crossterm::event::read()?.is_key_press() { |
|
| 21 | - | break Ok(()); |
|
| 27 | + | terminal.draw(|f| render(f, feed, &mut state))?; |
|
| 28 | + | ||
| 29 | + | if let crossterm::event::Event::Key(KeyEvent { code, .. }) = crossterm::event::read()? { |
|
| 30 | + | let len = feed.entries.len(); |
|
| 31 | + | match code { |
|
| 32 | + | KeyCode::Char('q') => break, |
|
| 33 | + | KeyCode::Char('j') | KeyCode::Down => { |
|
| 34 | + | let next = state.selected().map(|i| (i + 1).min(len - 1)).unwrap_or(0); |
|
| 35 | + | state.select(Some(next)); |
|
| 36 | + | } |
|
| 37 | + | KeyCode::Char('k') | KeyCode::Up => { |
|
| 38 | + | let prev = state.selected().map(|i| i.saturating_sub(1)).unwrap_or(0); |
|
| 39 | + | state.select(Some(prev)); |
|
| 40 | + | } |
|
| 41 | + | KeyCode::Enter => { |
|
| 42 | + | if let Some(i) = state.selected() { |
|
| 43 | + | if let Some(url) = feed.entries[i].links.first().map(|l| l.href.as_str()) { |
|
| 44 | + | let _ = open::that(url); |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | _ => {} |
|
| 49 | + | } |
|
| 22 | 50 | } |
|
| 23 | 51 | } |
|
| 52 | + | ||
| 53 | + | Ok(()) |
|
| 24 | 54 | } |
|
| 25 | 55 | ||
| 26 | - | fn render(frame: &mut Frame, feed: &ParsedFeed) { |
|
| 56 | + | fn fmt_date(raw: &str) -> String { |
|
| 57 | + | let Ok(dt) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S UTC") else { |
|
| 58 | + | return raw.to_string(); |
|
| 59 | + | }; |
|
| 60 | + | let day = dt.format("%e").to_string().trim().parse::<u32>().unwrap_or(0); |
|
| 61 | + | let suffix = match day { |
|
| 62 | + | 1 | 21 | 31 => "st", |
|
| 63 | + | 2 | 22 => "nd", |
|
| 64 | + | 3 | 23 => "rd", |
|
| 65 | + | _ => "th", |
|
| 66 | + | }; |
|
| 67 | + | format!("{} {}{}, {}", dt.format("%B"), day, suffix, dt.format("%Y")) |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | fn render(frame: &mut Frame, feed: &ParsedFeed, state: &mut ListState) { |
|
| 27 | 71 | let dim = Style::new().fg(Color::DarkGray); |
|
| 28 | 72 | let author_style = Style::new() |
|
| 29 | 73 | .fg(Color::DarkGray) |
|
| 30 | 74 | .add_modifier(Modifier::ITALIC); |
|
| 75 | + | let highlight = Style::new(); |
|
| 31 | 76 | ||
| 77 | + | let selected = state.selected(); |
|
| 32 | 78 | let items: Vec<ListItem> = feed |
|
| 33 | 79 | .entries |
|
| 34 | 80 | .iter() |
|
| 35 | - | .map(|e| { |
|
| 81 | + | .enumerate() |
|
| 82 | + | .map(|(i, e)| { |
|
| 83 | + | let bar = if selected == Some(i) { "▌ " } else { " " }; |
|
| 36 | 84 | let date = e |
|
| 37 | 85 | .published |
|
| 38 | 86 | .as_ref() |
|
| 39 | - | .map(|d| d.to_string()) |
|
| 87 | + | .map(|d| fmt_date(&d.to_string())) |
|
| 40 | 88 | .unwrap_or_else(|| "-".into()); |
|
| 41 | 89 | let title = e.title.as_deref().unwrap_or("(untitled)"); |
|
| 42 | 90 | let author = e |
|
| 45 | 93 | .and_then(|a| a.name.as_deref()) |
|
| 46 | 94 | .unwrap_or("anon"); |
|
| 47 | 95 | ListItem::new(Text::from(vec![ |
|
| 48 | - | Line::from(Span::styled(date, dim)), |
|
| 49 | - | Line::from(title.to_string()), |
|
| 50 | - | Line::from(Span::styled(author.to_string(), author_style)), |
|
| 96 | + | Line::from(vec![Span::raw(bar), Span::styled(date, dim)]), |
|
| 97 | + | Line::from(vec![Span::raw(bar), Span::raw(title.to_string())]), |
|
| 98 | + | Line::from(vec![Span::raw(bar), Span::styled(author.to_string(), author_style)]), |
|
| 51 | 99 | Line::from(""), |
|
| 52 | 100 | ])) |
|
| 53 | 101 | }) |
|
| 54 | 102 | .collect(); |
|
| 55 | - | frame.render_widget(List::new(items), frame.area()); |
|
| 103 | + | ||
| 104 | + | let outer = frame.area(); |
|
| 105 | + | let [_, center, _] = Layout::default() |
|
| 106 | + | .direction(Direction::Horizontal) |
|
| 107 | + | .constraints([ |
|
| 108 | + | Constraint::Fill(1), |
|
| 109 | + | Constraint::Max(80), |
|
| 110 | + | Constraint::Fill(1), |
|
| 111 | + | ]) |
|
| 112 | + | .areas(outer); |
|
| 113 | + | ||
| 114 | + | let block = Block::new().padding(Padding::symmetric(2, 1)); |
|
| 115 | + | let inner = block.inner(center); |
|
| 116 | + | frame.render_widget(block, center); |
|
| 117 | + | frame.render_stateful_widget( |
|
| 118 | + | List::new(items) |
|
| 119 | + | .highlight_style(highlight) |
|
| 120 | + | .highlight_symbol(""), |
|
| 121 | + | inner, |
|
| 122 | + | state, |
|
| 123 | + | ); |
|
| 56 | 124 | } |
|