Skip to main content

feed/tui/
ui.rs

1use ratatui::{
2    layout::{Constraint, Layout},
3    style::{Color, Modifier, Style},
4    text::{Line, Span},
5    widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
6    Frame,
7};
8
9use super::app::{App, Screen};
10
11pub fn render(frame: &mut Frame, app: &mut App) {
12    match app.screen {
13        Screen::ArticleList => render_article_list(frame, app),
14        Screen::ArticleView => render_article_view(frame, app),
15    }
16}
17
18fn render_article_list(frame: &mut Frame, app: &mut App) {
19    let area = frame.area();
20    let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
21
22    let items: Vec<ListItem> = app
23        .filtered_indices
24        .iter()
25        .filter_map(|&idx| app.store.get(idx))
26        .map(|entry| {
27            let date = entry
28                .published
29                .map(|dt| dt.format("%Y-%m-%d").to_string())
30                .unwrap_or_else(|| "          ".to_string());
31
32            if entry.read {
33                let line = Line::from(vec![
34                    Span::styled(date, Style::default().fg(Color::DarkGray)),
35                    Span::styled("  ", Style::default().fg(Color::DarkGray)),
36                    Span::styled(entry.title.clone(), Style::default().fg(Color::DarkGray)),
37                ]);
38                ListItem::new(line)
39            } else {
40                let line = Line::from(vec![
41                    Span::styled(date, Style::default().fg(Color::DarkGray)),
42                    Span::raw("  "),
43                    Span::raw(&entry.title),
44                ]);
45                ListItem::new(line)
46            }
47        })
48        .collect();
49
50    let title = if app.is_showing_read() {
51        " Feed Reader (all) "
52    } else {
53        " Feed Reader (unread) "
54    };
55
56    let list = List::new(items)
57        .block(Block::default().borders(Borders::ALL).title(title))
58        .highlight_style(
59            Style::default()
60                .fg(Color::Yellow)
61                .add_modifier(Modifier::BOLD),
62        )
63        .highlight_symbol("> ");
64
65    app.list_state.select(Some(app.selected));
66    app.layout_areas.main_area = chunks[0];
67    app.layout_areas.status_bar = chunks[1];
68    frame.render_stateful_widget(list, chunks[0], &mut app.list_state);
69
70    let status = if let Some(ref msg) = app.status_message {
71        msg.clone()
72    } else if app.loading {
73        " Loading...".to_string()
74    } else {
75        " Enter: open  m: read/unread  o: browser  a: +read/-read  r: refresh  Esc: quit"
76            .to_string()
77    };
78    let status_line = Paragraph::new(status).style(Style::default().fg(Color::DarkGray));
79    frame.render_widget(status_line, chunks[1]);
80}
81
82fn render_article_view(frame: &mut Frame, app: &mut App) {
83    let area = frame.area();
84    let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
85
86    app.layout_areas.main_area = chunks[0];
87    app.layout_areas.status_bar = chunks[1];
88
89    let title = app.article_title.as_deref().unwrap_or("Article");
90    let url = app.article_url.as_deref().unwrap_or("");
91    let content = app.article_content.as_deref().unwrap_or("");
92
93    let mut lines: Vec<Line> = Vec::new();
94    lines.push(Line::from(vec![Span::styled(
95        title,
96        Style::default().add_modifier(Modifier::BOLD),
97    )]));
98    lines.push(Line::from(vec![Span::styled(
99        url,
100        Style::default().fg(Color::DarkGray),
101    )]));
102    lines.push(Line::from(""));
103    for line in content.lines() {
104        lines.push(Line::from(line));
105    }
106
107    let paragraph = Paragraph::new(lines)
108        .block(Block::default().borders(Borders::ALL))
109        .wrap(Wrap { trim: false })
110        .scroll((u16::try_from(app.scroll_offset).unwrap_or(u16::MAX), 0));
111
112    frame.render_widget(paragraph, chunks[0]);
113
114    let read_label = if app.current_article().is_some_and(|a| a.read) {
115        "unread"
116    } else {
117        "read"
118    };
119    let status = format!(" m: {}  o: browser  Esc: back", read_label);
120    let status_line = Paragraph::new(status).style(Style::default().fg(Color::DarkGray));
121    frame.render_widget(status_line, chunks[1]);
122}