use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
prelude::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use crate::app::{App, FeedStatus, InputMode, PageMode, Theme};
use chrono::{DateTime, Local};
pub fn parse_color(color_name: &str) -> Color {
match color_name.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"dark_gray" | "dark_grey" | "darkgray" | "darkgrey" => Color::DarkGray,
"light_red" | "lightred" => Color::LightRed,
"light_green" | "lightgreen" => Color::LightGreen,
"light_yellow" | "lightyellow" => Color::LightYellow,
"light_blue" | "lightblue" => Color::LightBlue,
"light_magenta" | "lightmagenta" => Color::LightMagenta,
"light_cyan" | "lightcyan" => Color::LightCyan,
"white" => Color::White,
s if s.starts_with('#') && s.len() == 7 => {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&s[1..3], 16),
u8::from_str_radix(&s[3..5], 16),
u8::from_str_radix(&s[5..7], 16),
) {
Color::Rgb(r, g, b)
} else {
Color::White }
}
s if s.len() == 6 => {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&s[0..2], 16),
u8::from_str_radix(&s[2..4], 16),
u8::from_str_radix(&s[4..6], 16),
) {
Color::Rgb(r, g, b)
} else {
Color::White }
}
_ => Color::White, }
}
struct ThemeColors {
primary: Color,
secondary: Color,
text: Color,
muted: Color,
error: Color,
highlight: Color,
description: Color,
category: Color,
}
impl ThemeColors {
fn from_theme(theme: &Theme) -> Self {
Self {
primary: parse_color(&theme.primary),
secondary: parse_color(&theme.secondary),
text: parse_color(&theme.text),
muted: parse_color(&theme.muted),
error: parse_color(&theme.error),
highlight: parse_color(&theme.highlight),
description: parse_color(&theme.description),
category: parse_color(&theme.category),
}
}
}
pub fn render(app: &App, frame: &mut Frame) {
let colors = ThemeColors::from_theme(&app.config.theme);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let base_title = match app.page_mode {
PageMode::FeedList => "Reedy",
PageMode::FeedManager => "Feed Manager",
PageMode::Favorites => "Favorites",
};
let mut title_text = base_title.to_string();
if app.show_unread_only {
title_text = format!("{} [Unread Only]", title_text);
}
if let Some(remaining) = app.time_until_next_refresh() {
let mins = remaining.as_secs() / 60;
let secs = remaining.as_secs() % 60;
title_text = format!("{} [Auto: {}:{:02}]", title_text, mins, secs);
}
let title_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(35), Constraint::Percentage(30), Constraint::Percentage(35), ])
.split(chunks[0]);
let book_art = vec![
Line::from(" ┌─────┐ "),
Line::from(" ┌│░░░░░│┐ "),
Line::from(" ┌││░░░░░││┐"),
Line::from(" ││└─────┘││"),
Line::from(" └└───────┘┘"),
];
let book_para = Paragraph::new(book_art)
.style(Style::default().fg(colors.primary))
.alignment(Alignment::Center);
let title_para = Paragraph::new(title_text.clone())
.style(Style::default().fg(colors.primary))
.alignment(Alignment::Center);
let title_block = Block::default().borders(Borders::ALL);
frame.render_widget(title_block, chunks[0]);
frame.render_widget(book_para, title_layout[1]);
frame.render_widget(
Paragraph::new(title_text.clone())
.style(Style::default().fg(colors.primary))
.alignment(Alignment::Center),
title_layout[0],
);
frame.render_widget(title_para, title_layout[2]);
if app.input_mode == InputMode::Help {
render_help_menu(app, frame, chunks[1], &colors);
} else if app.input_mode == InputMode::Preview {
render_article_preview(app, frame, chunks[1], &colors);
} else {
match app.page_mode {
PageMode::FeedList => render_feed_content(app, frame, chunks[1], &colors),
PageMode::FeedManager => render_feed_manager(app, frame, chunks[1], &colors),
PageMode::Favorites => render_feed_content(app, frame, chunks[1], &colors),
}
}
render_command_bar(app, frame, chunks[2], &colors);
}
fn render_feed_content(app: &App, frame: &mut Frame, area: Rect, colors: &ThemeColors) {
let items_per_page = ((area.height as usize).saturating_sub(2) / 3).max(1);
let visible_items = app.get_visible_items();
let total_visible = visible_items.len();
let total_items = app.current_feed_content.len();
let start_idx = app.scroll as usize;
let end_idx = (start_idx + items_per_page).min(total_visible);
let items: Vec<ListItem> = visible_items
.iter()
.enumerate()
.skip(start_idx)
.take(items_per_page)
.map(|(visible_idx, (_actual_idx, item))| {
let style = if Some(visible_idx) == app.selected_index {
Style::default()
.fg(colors.secondary)
.add_modifier(Modifier::REVERSED)
} else if app.is_item_read(item) {
Style::default().fg(colors.muted)
} else {
Style::default().fg(colors.text)
};
let favorite_indicator = if app.is_item_favorite(item) {
"★ "
} else {
" "
};
let date_str = item.published.map_or_else(
|| "No date".to_string(),
|date| {
let datetime: DateTime<Local> = date.into();
datetime.format("%Y-%m-%d %H:%M").to_string()
},
);
let title_max_width = area.width.saturating_sub(10) as usize; let desc_max_width = area.width.saturating_sub(6) as usize;
let truncated_title = truncate_text(&item.title, title_max_width as u16);
let truncated_desc = truncate_text(&item.description, desc_max_width as u16);
ListItem::new(vec![
Line::from(vec![
Span::styled(favorite_indicator, style),
Span::styled(
format!("[{}] ", if app.is_item_read(item) { "✓" } else { " " }),
style,
),
Span::styled(truncated_title, style.add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(date_str, Style::default().fg(colors.secondary)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(truncated_desc, Style::default().fg(colors.description)),
]),
])
})
.collect();
let page_count = if total_visible == 0 {
1
} else {
total_visible.div_ceil(items_per_page)
};
let current_page = if total_visible == 0 {
1
} else {
(start_idx / items_per_page) + 1
};
let title = if app.filtered_indices.is_some() {
format!(
"Feed Content [Filter: \"{}\"] (Page {}/{}, Items {}-{}/{} of {})",
app.search_query,
current_page,
page_count,
if total_visible == 0 { 0 } else { start_idx + 1 },
end_idx,
total_visible,
total_items
)
} else {
format!(
"Feed Content (Page {}/{}, Items {}-{}/{})",
current_page,
page_count,
if total_visible == 0 { 0 } else { start_idx + 1 },
end_idx,
total_visible
)
};
let list = List::new(items)
.block(Block::default().title(title).borders(Borders::ALL))
.style(Style::default().fg(colors.text));
frame.render_widget(list, area);
}
fn render_feed_manager(app: &App, frame: &mut Frame, area: Rect, colors: &ThemeColors) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(1), ])
.split(area);
let feeds_by_category = app.get_feeds_by_category();
let mut items: Vec<ListItem> = Vec::new();
let mut feed_index = 0;
for (category, feeds) in &feeds_by_category {
let header_text = match category {
Some(cat) => format!("── {} ──", cat),
None => "── Uncategorized ──".to_string(),
};
items.push(ListItem::new(Line::from(Span::styled(
header_text,
Style::default()
.fg(colors.category)
.add_modifier(Modifier::BOLD),
))));
for feed_info in feeds {
let style = if Some(feed_index) == app.selected_index {
Style::default()
.fg(colors.secondary)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(colors.text)
};
let unread_count = app.count_unread_for_feed(&feed_info.url);
let total_count = app.count_total_for_feed(&feed_info.url);
let health = app.get_feed_health(&feed_info.url);
let health_indicator = health.status_indicator();
let health_style = match health.status {
FeedStatus::Healthy => Style::default().fg(Color::Green),
FeedStatus::Slow => Style::default().fg(Color::Yellow),
FeedStatus::Broken => Style::default().fg(Color::Red),
FeedStatus::Unknown => Style::default().fg(colors.muted),
};
let count_display = if total_count > 0 {
format!(" ({}/{})", unread_count, total_count)
} else {
String::new()
};
let count_len = count_display.len();
let title_max_width = chunks[0].width.saturating_sub(15 + count_len as u16) as usize; let truncated_title = truncate_text(&feed_info.title, title_max_width as u16);
let count_style = if unread_count > 0 {
Style::default()
.fg(colors.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.muted)
};
items.push(
ListItem::new(Line::from(vec![
Span::styled(format!("{} ", health_indicator), health_style),
Span::raw(format!("{}. ", feed_index + 1)),
Span::raw(truncated_title),
Span::styled(count_display, count_style),
]))
.style(style),
);
feed_index += 1;
}
}
let list = List::new(items)
.block(Block::default().title("RSS Feeds").borders(Borders::ALL))
.style(Style::default().fg(colors.text));
frame.render_widget(list, chunks[0]);
if let Some(error) = &app.error_message {
let error_text = Line::from(vec![
Span::styled("Error: ", Style::default().fg(colors.error)),
Span::styled(error, Style::default().fg(colors.error)),
]);
let paragraph = Paragraph::new(error_text).style(Style::default().fg(colors.error));
frame.render_widget(paragraph, chunks[1]);
} else if let Some(selected_index) = app.selected_index {
if let Some(feed_info) = app.rss_feeds.get(selected_index) {
let health = app.get_feed_health(&feed_info.url);
let status_color = match health.status {
FeedStatus::Healthy => Color::Green,
FeedStatus::Slow => Color::Yellow,
FeedStatus::Broken => Color::Red,
FeedStatus::Unknown => colors.muted,
};
let status_text = Line::from(vec![
Span::styled(
format!("{} ", health.status_indicator()),
Style::default().fg(status_color),
),
Span::styled(
health.status_description(),
Style::default().fg(status_color),
),
]);
let paragraph = Paragraph::new(status_text);
frame.render_widget(paragraph, chunks[1]);
}
}
}
fn format_keybinding(keybinding: &str) -> String {
let keys: Vec<&str> = keybinding.split(',').collect();
let formatted: Vec<String> = keys
.iter()
.map(|k| {
let k = k.trim();
match k.to_lowercase().as_str() {
"up" => "↑".to_string(),
"down" => "↓".to_string(),
"left" => "←".to_string(),
"right" => "→".to_string(),
"pageup" | "pgup" => "PgUp".to_string(),
"pagedown" | "pgdn" | "pgdown" => "PgDn".to_string(),
"enter" | "return" => "Enter".to_string(),
"esc" | "escape" => "Esc".to_string(),
"space" => "Space".to_string(),
_ => k.to_string(),
}
})
.collect();
formatted.join("/")
}
fn render_help_menu(app: &App, frame: &mut Frame, area: Rect, colors: &ThemeColors) {
let title = "Help - Available Commands";
let kb = &app.config.keybindings;
let help_text = match app.page_mode {
PageMode::FeedList => vec![
Line::from(vec![Span::styled(
"Feed List Commands",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(colors.primary),
)]),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Navigate between feed items",
format_keybinding(&kb.move_up) + ", " + &format_keybinding(&kb.move_down)
)),
Line::from(format!(
"{:<14} - Scroll page up/down",
format_keybinding(&kb.page_up) + ", " + &format_keybinding(&kb.page_down)
)),
Line::from(format!(
"{:<14} - Scroll to top of feed",
format_keybinding(&kb.scroll_to_top)
)),
Line::from(format!(
"{:<14} - Scroll to bottom of feed",
format_keybinding(&kb.scroll_to_bottom)
)),
Line::from(format!(
"{:<14} - Read selected feed",
format_keybinding(&kb.select)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Search & Filter",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Start search/filter",
format_keybinding(&kb.start_search)
)),
Line::from(format!(
"{:<14} - Toggle unread-only filter",
format_keybinding(&kb.toggle_unread_only)
)),
Line::from("Esc - Clear all filters"),
Line::from(""),
Line::from(vec![Span::styled(
"Actions",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Open article preview pane",
format_keybinding(&kb.open_preview)
)),
Line::from(format!(
"{:<14} - Open selected item in browser",
format_keybinding(&kb.open_in_browser)
)),
Line::from(format!(
"{:<14} - Copy link to clipboard",
format_keybinding(&kb.copy_link)
)),
Line::from(format!(
"{:<14} - Toggle read status of selected item",
format_keybinding(&kb.toggle_read)
)),
Line::from(format!(
"{:<14} - Mark all items as read",
format_keybinding(&kb.mark_all_read)
)),
Line::from(format!(
"{:<14} - Toggle favorite status of selected item",
format_keybinding(&kb.toggle_favorite)
)),
Line::from(format!(
"{:<14} - Toggle favorites view",
format_keybinding(&kb.toggle_favorites_view)
)),
Line::from(format!(
"{:<14} - Open feed manager",
format_keybinding(&kb.open_feed_manager)
)),
Line::from(format!(
"{:<14} - Refresh feed cache",
format_keybinding(&kb.refresh)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Export",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Copy article to clipboard (markdown)",
format_keybinding(&kb.export_article)
)),
Line::from("S - Save article to file"),
Line::from(""),
Line::from(vec![Span::styled(
"UI",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Toggle this help menu",
format_keybinding(&kb.help)
)),
Line::from(format!(
"{:<14} - Quit application",
format_keybinding(&kb.quit)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Vi-Style Commands",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(": - Enter command mode"),
Line::from(":q - Quit application"),
Line::from(":w - Save state"),
Line::from(":wq - Save and quit"),
Line::from(":help - Show help"),
Line::from(":feeds - Open feed manager"),
Line::from(":fav - Toggle favorites view"),
Line::from(":read - Mark all as read"),
],
PageMode::FeedManager => vec![
Line::from(vec![Span::styled(
"Feed Manager Commands",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(colors.primary),
)]),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Navigate between feeds",
format_keybinding(&kb.move_up) + ", " + &format_keybinding(&kb.move_down)
)),
Line::from(format!(
"{:<14} - Scroll to top of feed list",
format_keybinding(&kb.scroll_to_top)
)),
Line::from(format!(
"{:<14} - Scroll to bottom of feed list",
format_keybinding(&kb.scroll_to_bottom)
)),
Line::from(format!(
"{:<14} - Select feed and return to feed list",
format_keybinding(&kb.select)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Actions",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Add new feed",
format_keybinding(&kb.add_feed)
)),
Line::from(format!(
"{:<14} - Delete selected feed",
format_keybinding(&kb.delete_feed)
)),
Line::from(format!(
"{:<14} - Set category/tag for selected feed",
format_keybinding(&kb.set_category)
)),
Line::from(format!(
"{:<14} - Export feeds to clipboard",
format_keybinding(&kb.export_clipboard)
)),
Line::from(format!(
"{:<14} - Export feeds to OPML file",
format_keybinding(&kb.export_opml)
)),
Line::from(format!(
"{:<14} - Import feeds from clipboard",
format_keybinding(&kb.import_clipboard)
)),
Line::from(format!(
"{:<14} - Import feeds from OPML file",
format_keybinding(&kb.import_opml)
)),
Line::from(format!(
"{:<14} - Refresh feed cache",
format_keybinding(&kb.refresh)
)),
Line::from(format!(
"{:<14} - Return to feed list",
format_keybinding(&kb.open_feed_manager)
)),
Line::from(format!(
"{:<14} - Toggle this help menu",
format_keybinding(&kb.help)
)),
Line::from(format!(
"{:<14} - Quit application",
format_keybinding(&kb.quit) + "/Esc"
)),
Line::from(""),
Line::from(vec![Span::styled(
"Vi-Style Commands",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(": - Enter command mode"),
Line::from(":q - Quit application"),
Line::from(":w - Save state"),
Line::from(":wq - Save and quit"),
Line::from(":help - Show help"),
],
PageMode::Favorites => vec![
Line::from(vec![Span::styled(
"Favorites View Commands",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(colors.primary),
)]),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Navigate between favorite items",
format_keybinding(&kb.move_up) + ", " + &format_keybinding(&kb.move_down)
)),
Line::from(format!(
"{:<14} - Scroll page up/down",
format_keybinding(&kb.page_up) + ", " + &format_keybinding(&kb.page_down)
)),
Line::from(format!(
"{:<14} - Scroll to top of feed",
format_keybinding(&kb.scroll_to_top)
)),
Line::from(format!(
"{:<14} - Scroll to bottom of feed",
format_keybinding(&kb.scroll_to_bottom)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Search & Filter",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Start search/filter",
format_keybinding(&kb.start_search)
)),
Line::from(format!(
"{:<14} - Toggle unread-only filter",
format_keybinding(&kb.toggle_unread_only)
)),
Line::from("Esc - Clear all filters"),
Line::from(""),
Line::from(vec![Span::styled(
"Actions",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Open article preview pane",
format_keybinding(&kb.open_preview)
)),
Line::from(format!(
"{:<14} - Open selected item in browser",
format_keybinding(&kb.open_in_browser)
)),
Line::from(format!(
"{:<14} - Copy link to clipboard",
format_keybinding(&kb.copy_link)
)),
Line::from(format!(
"{:<14} - Remove item from favorites",
format_keybinding(&kb.toggle_favorite)
)),
Line::from(format!(
"{:<14} - Return to all feeds view",
format_keybinding(&kb.toggle_favorites_view)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Export",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Copy article to clipboard (markdown)",
format_keybinding(&kb.export_article)
)),
Line::from("S - Save article to file"),
Line::from(""),
Line::from(vec![Span::styled(
"UI",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(format!(
"{:<14} - Toggle this help menu",
format_keybinding(&kb.help)
)),
Line::from(format!(
"{:<14} - Quit application",
format_keybinding(&kb.quit)
)),
Line::from(""),
Line::from(vec![Span::styled(
"Vi-Style Commands",
Style::default()
.add_modifier(Modifier::UNDERLINED)
.fg(colors.secondary),
)]),
Line::from(": - Enter command mode"),
Line::from(":q - Quit application"),
Line::from(":w - Save state"),
Line::from(":wq - Save and quit"),
Line::from(":help - Show help"),
Line::from(":fav - Return to all feeds view"),
],
};
let help_paragraph = Paragraph::new(help_text)
.block(Block::default().title(title).borders(Borders::ALL))
.style(Style::default().fg(colors.text));
frame.render_widget(help_paragraph, area);
}
fn render_article_preview(app: &App, frame: &mut Frame, area: Rect, colors: &ThemeColors) {
let Some(item) = app.get_preview_item() else {
let paragraph = Paragraph::new("No article selected")
.block(
Block::default()
.title("Article Preview")
.borders(Borders::ALL),
)
.style(Style::default().fg(colors.description));
frame.render_widget(paragraph, area);
return;
};
let (article_title, feed_name) = if let Some(pos) = item.title.rfind(" | ") {
(&item.title[..pos], &item.title[pos + 3..])
} else {
(item.title.as_str(), "")
};
let date_str = item.published.map_or_else(
|| "No date".to_string(),
|date| {
let datetime: DateTime<Local> = date.into();
datetime.format("%Y-%m-%d %H:%M").to_string()
},
);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
article_title,
Style::default()
.add_modifier(Modifier::BOLD)
.fg(colors.primary),
)]));
lines.push(Line::from(""));
if !feed_name.is_empty() {
lines.push(Line::from(vec![
Span::styled("Feed: ", Style::default().fg(colors.secondary)),
Span::styled(feed_name, Style::default().fg(colors.text)),
]));
}
lines.push(Line::from(vec![
Span::styled("Date: ", Style::default().fg(colors.secondary)),
Span::styled(date_str, Style::default().fg(colors.text)),
]));
if !item.link.is_empty() {
lines.push(Line::from(vec![
Span::styled("Link: ", Style::default().fg(colors.secondary)),
Span::styled(&item.link, Style::default().fg(colors.highlight)),
]));
}
let status = format!(
"Status: {}{}",
if app.is_item_read(item) {
"[Read] "
} else {
"[Unread] "
},
if app.is_item_favorite(item) {
"★ Favorite"
} else {
""
}
);
lines.push(Line::from(vec![Span::styled(
status,
Style::default().fg(colors.description),
)]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
"─".repeat(area.width.saturating_sub(4) as usize),
Style::default().fg(colors.muted),
)]));
lines.push(Line::from(""));
let content_width = area.width.saturating_sub(4) as usize;
for line in item.description.lines() {
let wrapped = wrap_text(line, content_width);
for wrapped_line in wrapped {
lines.push(Line::from(wrapped_line));
}
}
let total_lines = lines.len();
let visible_height = area.height.saturating_sub(2) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = (app.preview_scroll as usize).min(max_scroll);
let title = if total_lines > visible_height {
format!("Article Preview (Line {}/{})", scroll + 1, total_lines)
} else {
"Article Preview".to_string()
};
let paragraph = Paragraph::new(lines)
.block(Block::default().title(title).borders(Borders::ALL))
.style(Style::default().fg(colors.text))
.scroll((scroll as u16, 0));
frame.render_widget(paragraph, area);
}
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return vec![];
}
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
if word.len() > max_width {
let mut remaining = word;
while remaining.len() > max_width {
lines.push(remaining[..max_width].to_string());
remaining = &remaining[max_width..];
}
current_line = remaining.to_string();
} else {
current_line = word.to_string();
}
} else if current_line.len() + 1 + word.len() <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
if word.len() > max_width {
let mut remaining = word;
while remaining.len() > max_width {
lines.push(remaining[..max_width].to_string());
remaining = &remaining[max_width..];
}
current_line = remaining.to_string();
} else {
current_line = word.to_string();
}
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub fn truncate_text(text: &str, max_width: u16) -> String {
if max_width < 3 {
return text.chars().take(max_width as usize).collect();
}
if text.len() <= max_width as usize {
text.to_string()
} else {
let mut truncated = text
.chars()
.take((max_width - 3) as usize)
.collect::<String>();
truncated.push_str("...");
truncated
}
}
fn render_command_bar(app: &App, frame: &mut Frame, area: Rect, colors: &ThemeColors) {
let commands = if app.input_mode == InputMode::Help {
"[q/Esc/?] Exit Help".to_string()
} else if app.input_mode == InputMode::Preview {
"[↑↓/jk] Scroll [PgUp/PgDn] Page [o/O] Open/Copy [r] Read [f] Fav [s] Copy [S] Save [Esc/q/p] Close".to_string()
} else if app.input_mode == InputMode::Searching {
format!(
"Search: {}█ [Enter] Confirm [Esc] Cancel",
app.search_query
)
} else if app.input_mode == InputMode::Command {
format!(":{}█ [Enter] Execute [Esc] Cancel", app.command_buffer)
} else {
match app.page_mode {
PageMode::FeedList => {
if app.current_feed_content.is_empty() {
"[m] Manage Feeds [c] Refresh Cache [F] Favorites [?] Help [q] Quit".to_string()
} else if app.filtered_indices.is_some() {
"[↑↓] Navigate [/] Search [u] Unread [Esc] Clear [p] Preview [o/O] Open/Copy [f] Fav [?] Help".to_string()
} else {
"[↑↓] Navigate [/] Search [u] Unread [p] Preview [o/O] Open/Copy [m] Manage [r] Read [f] Fav [?] Help".to_string()
}
}
PageMode::Favorites => {
if app.current_feed_content.is_empty() {
"[F] Back to Feeds [?] Help [q] Quit".to_string()
} else if app.filtered_indices.is_some() {
"[↑↓] Navigate [/] Search [u] Unread [Esc] Clear [p] Preview [o/O] Open/Copy [f] Fav [F] Back [?] Help".to_string()
} else {
"[↑↓] Navigate [/] Search [u] Unread [p] Preview [o/O] Open/Copy [f] Fav [F] Back [?] Help".to_string()
}
}
PageMode::FeedManager => match app.input_mode {
InputMode::Normal => {
"[↑↓] Navigate [a] Add [d] Delete [t] Tag [e/E] Export [i/I] Import [m] Back [?] Help [q] Quit".to_string()
}
InputMode::Adding => format!("Enter RSS URL: {}", app.input_buffer),
InputMode::Deleting => {
"Use ↑↓ to select feed, Enter to delete, Esc to cancel".to_string()
}
InputMode::Importing => {
let line_count = app.input_buffer.lines().count();
format!("Import feeds ({} URLs pasted) - [Enter] Import [Esc] Cancel", line_count)
}
InputMode::SettingCategory => {
format!("Set category: {}█ [Enter] Save [Esc] Cancel (empty to remove)", app.input_buffer)
}
InputMode::FeedManager => "[m] Back to Feeds [?] Help".to_string(),
InputMode::Help | InputMode::Searching | InputMode::Preview | InputMode::Command => unreachable!(), },
}
};
let display_text = if let Some(status) = &app.status_message {
Line::from(vec![
Span::styled(
format!(" {} ", status),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(" | ", Style::default().fg(colors.muted)),
Span::styled(
truncate_text(
&commands,
area.width.saturating_sub(status.len() as u16 + 8),
),
Style::default().fg(colors.secondary),
),
])
} else if let Some(error) = &app.error_message {
Line::from(vec![
Span::styled(
format!(" {} ", error),
Style::default()
.fg(colors.error)
.add_modifier(Modifier::BOLD),
),
Span::styled(" | ", Style::default().fg(colors.muted)),
Span::styled(
truncate_text(&commands, area.width.saturating_sub(error.len() as u16 + 8)),
Style::default().fg(colors.secondary),
),
])
} else {
Line::from(truncate_text(&commands, area.width.saturating_sub(2)))
};
let command_bar = Paragraph::new(display_text)
.style(Style::default().fg(colors.secondary))
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Left);
frame.render_widget(command_bar, area);
}