chore: refinements 3dc1c298
Steve · 2026-05-06 11:55 2 file(s) · +83 −13
Cargo.toml +2 −0
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
src/main.rs +81 −13
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
}