use crate::app::{App, InputMode, View};
use crate::config::Theme;
use crate::keybindings::{key_display, KeyAction};
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols::{self},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Padding, Paragraph, Tabs},
Frame,
};
mod categories;
mod dashboard;
mod detail;
mod feed_items;
mod feed_list;
mod modals;
mod starred;
mod summary;
pub(crate) mod utils;
use categories::{render_category_input_modal, render_category_management};
use dashboard::render_dashboard;
use detail::render_item_detail;
use feed_items::render_feed_items;
use feed_list::render_feed_list;
use modals::{
render_error_modal, render_feed_selection_modal, render_filter_modal, render_help_overlay,
render_input_modal, render_link_overlay, render_success_notification,
};
use starred::render_starred;
use summary::render_summary;
pub use feed_list::extract_domain;
#[derive(Clone, Debug)]
pub struct ColorScheme {
pub primary: Color,
pub secondary: Color,
pub highlight: Color,
pub success: Color,
pub background: Color,
pub surface: Color,
pub selected_bg: Color,
pub text: Color,
pub text_secondary: Color,
pub muted: Color,
pub accent: Color,
pub error: Color,
pub border: Color,
pub border_focus: Color,
pub border_normal: BorderType,
pub border_active: BorderType,
pub border_focus_type: BorderType,
}
impl ColorScheme {
pub fn dark() -> Self {
Self {
primary: Color::Rgb(0, 217, 255), secondary: Color::Rgb(255, 0, 255), highlight: Color::Rgb(0, 255, 157), success: Color::Rgb(57, 255, 20), background: Color::Rgb(10, 10, 10), surface: Color::Rgb(15, 20, 25), selected_bg: Color::Rgb(30, 30, 50), text: Color::Rgb(255, 255, 255), text_secondary: Color::Rgb(150, 200, 255), muted: Color::Rgb(100, 100, 120), accent: Color::Rgb(255, 215, 0), error: Color::Rgb(255, 20, 147), border: Color::Rgb(80, 80, 120), border_focus: Color::Rgb(0, 217, 255), border_normal: BorderType::Double,
border_active: BorderType::Double,
border_focus_type: BorderType::Thick,
}
}
pub fn light() -> Self {
Self {
primary: Color::Rgb(92, 138, 126), secondary: Color::Rgb(201, 112, 100), highlight: Color::Rgb(218, 165, 32), success: Color::Rgb(106, 153, 85), background: Color::Rgb(250, 248, 245), surface: Color::Rgb(255, 255, 252), selected_bg: Color::Rgb(237, 231, 220), text: Color::Rgb(60, 50, 40), text_secondary: Color::Rgb(120, 110, 100), muted: Color::Rgb(170, 165, 155), accent: Color::Rgb(184, 134, 100), error: Color::Rgb(180, 80, 70), border: Color::Rgb(200, 195, 185), border_focus: Color::Rgb(92, 138, 126), border_normal: BorderType::Rounded,
border_active: BorderType::Rounded,
border_focus_type: BorderType::Rounded,
}
}
pub fn from_theme(theme: &Theme) -> Self {
match theme {
Theme::Dark => Self::dark(),
Theme::Light => Self::light(),
}
}
pub fn get_list_bullet(&self) -> &str {
if self.border_normal == BorderType::Double {
"◆" } else {
"◦" }
}
pub fn get_arrow_right(&self) -> &str {
if self.border_normal == BorderType::Double {
"▸" } else {
"›" }
}
pub fn get_selection_indicator(&self) -> &str {
if self.border_normal == BorderType::Double {
"▶" } else {
"•" }
}
pub fn get_loading_frames(&self) -> Vec<&str> {
if self.border_normal == BorderType::Double {
vec!["◢", "◣", "◤", "◥", "◢", "◣", "◤", "◥", "◢", "◣"]
} else {
vec!["⋯", "⋰", "⋱", "⋯", "⋰", "⋱", "⋯", "⋰", "⋱", "⋯"]
}
}
pub fn get_empty_feed_art(&self) -> &'static [&'static str] {
if self.border_normal == BorderType::Double {
&[
" ",
" ╔═══════════════════╗ ",
" ║ ◢◣ C Y B E R ◤◥ ║ ",
" ║ ═══════════════ ║ ",
" ║ > NO_SIGNAL_ ║ ",
" ║ > INIT_FEED... ║ ",
" ╚═══════════════════╝ ",
" ",
]
} else {
&[
" ",
" _ ",
" ( ) ",
" | ",
" / \\ ",
" / \\ ",
" ------- ",
" ",
]
}
}
pub fn get_dashboard_art(&self) -> &'static [&'static str] {
if self.border_normal == BorderType::Double {
&[
" ",
" ███████╗███████╗███████╗██████╗ ██████╗ ",
" ██╔════╝██╔════╝██╔════╝██╔══██╗██╔══██╗ ",
" █████╗ █████╗ █████╗ ██║ ██║██████╔╝ ",
" ██╔══╝ ██╔══╝ ██╔══╝ ██║ ██║██╔══██╗ ",
" ██║ ███████╗███████╗██████╔╝██║ ██║ ",
" ╚═╝ ╚══════╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ",
" ═══════════════════════════════════════════ ",
" ◢◣ NEURAL FEED INTERFACE v2.0 ◤◥ ",
" ═══════════════════════════════════════════ ",
" ",
" ▸ INITIALIZE: Press 'a' to add feed URL ",
" ▸ CONNECT TO DATA STREAMS ",
" ",
]
} else {
&[
" ",
" ",
" F e e d r ",
" ",
" ───────────────── ",
" ",
" A mindful RSS reader ",
" ",
" ",
" 🍃 Begin by adding your first feed ",
" Press 'a' to add a feed URL ",
" ",
" ",
]
}
}
pub fn get_icon_feed(&self) -> &str {
if self.border_normal == BorderType::Double {
"◈" } else {
"🍃" }
}
pub fn get_icon_article(&self) -> &str {
if self.border_normal == BorderType::Double {
"◇" } else {
"📄" }
}
pub fn get_icon_search(&self) -> &str {
if self.border_normal == BorderType::Double {
"◎" } else {
"🔍" }
}
pub fn get_icon_dashboard(&self) -> &str {
if self.border_normal == BorderType::Double {
"◢◣" } else {
"☀️" }
}
pub fn get_icon_error(&self) -> &str {
"⚠" }
pub fn get_icon_success(&self) -> &str {
"✓" }
}
pub fn render<B: Backend>(f: &mut Frame<B>, app: &mut App) {
let colors = app.color_scheme.clone();
let bg_block = Block::default().style(Style::default().bg(colors.background));
f.render_widget(bg_block, f.size());
let chunks = if app.compact {
Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(f.size())
} else {
Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(4), Constraint::Min(0), Constraint::Length(4), ])
.split(f.size())
};
if app.compact {
render_compact_title_bar(f, app, chunks[0], &colors);
} else {
render_title_bar(f, app, chunks[0], &colors);
}
match app.view {
View::Dashboard => render_dashboard(f, app, chunks[1], &colors),
View::FeedList => render_feed_list(f, app, chunks[1], &colors),
View::FeedItems => render_feed_items(f, app, chunks[1], &colors),
View::FeedItemDetail => render_item_detail(f, app, chunks[1], &colors),
View::CategoryManagement => render_category_management(f, app, chunks[1], &colors),
View::Starred => render_starred(f, app, chunks[1], &colors),
View::Summary => render_summary(f, app, chunks[1], &colors),
}
if app.compact {
render_compact_help_bar(f, app, chunks[2], &colors);
} else {
render_help_bar(f, app, chunks[2], &colors);
}
if let Some(error) = &app.error {
render_error_modal(f, error, &colors);
}
if let Some(success) = &app.success_message {
render_success_notification(f, success, &colors);
}
if matches!(app.input_mode, InputMode::InsertUrl | InputMode::SearchMode) {
render_input_modal(f, app, &colors);
}
if app.input_mode == InputMode::SelectDiscoveredFeed {
render_feed_selection_modal(f, app, &colors);
}
if app.filter_mode {
render_filter_modal(f, app, &colors);
}
if app.input_mode == InputMode::CategoryNameInput {
render_category_input_modal(f, app, &colors);
}
if app.show_link_overlay {
render_link_overlay(f, app, &colors);
}
if app.show_help_overlay {
render_help_overlay(f, app, &colors);
}
}
fn render_title_bar<B: Backend>(f: &mut Frame<B>, app: &App, area: Rect, colors: &ColorScheme) {
let titles = [
"Dashboard",
"Feeds",
"Items",
"Detail",
"Categories",
"Starred",
"What's New",
];
let selected_tab = match app.view {
View::Dashboard => 0,
View::FeedList => 1,
View::FeedItems => 2,
View::FeedItemDetail => 3,
View::CategoryManagement => 4,
View::Starred => 5,
View::Summary => 6,
};
let loading_symbols = colors.get_loading_frames();
let title = if app.is_loading {
format!(
" {} Refreshing feeds... ",
loading_symbols[app.loading_indicator % loading_symbols.len()]
)
} else {
format!(" {} Feedr ", colors.get_icon_dashboard())
};
let selection_indicator = colors.get_selection_indicator();
let tabs = Tabs::new(
titles
.iter()
.enumerate()
.map(|(i, t)| {
let prefix = if i == selected_tab {
format!("{} ", selection_indicator)
} else {
" ".to_string()
};
Line::from(vec![Span::styled(
format!("{}{}", prefix, t),
if i == selected_tab {
Style::default()
.fg(colors.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.text_secondary)
},
)])
})
.collect(),
)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(if app.is_loading {
colors.border_active
} else {
colors.border_normal
})
.border_style(Style::default().fg(if app.is_loading {
colors.highlight
} else {
colors.border
}))
.title(title)
.title_alignment(Alignment::Center)
.padding(Padding::new(2, 2, 0, 0)),
)
.style(
Style::default()
.fg(colors.text_secondary)
.bg(colors.surface),
)
.select(selected_tab)
.divider(symbols::line::VERTICAL);
f.render_widget(tabs, area);
}
fn render_compact_title_bar<B: Backend>(
f: &mut Frame<B>,
app: &App,
area: Rect,
colors: &ColorScheme,
) {
let view_name = match app.view {
View::Dashboard => "Dashboard",
View::FeedList => "Feeds",
View::FeedItems => "Items",
View::FeedItemDetail => "Detail",
View::CategoryManagement => "Categories",
View::Starred => "Starred",
View::Summary => "What's New",
};
let title = if app.is_loading {
let frames = colors.get_loading_frames();
format!(
" Feedr > {} {} ",
view_name,
frames[app.loading_indicator % frames.len()]
)
} else {
format!(" Feedr > {} ", view_name)
};
let bar = Paragraph::new(Line::from(vec![Span::styled(
title,
Style::default()
.fg(colors.primary)
.add_modifier(Modifier::BOLD)
.bg(colors.surface),
)]))
.style(Style::default().bg(colors.surface));
f.render_widget(bar, area);
}
fn render_help_bar<B: Backend>(f: &mut Frame<B>, app: &App, area: Rect, colors: &ColorScheme) {
let (help_text, _style) = match app.input_mode {
InputMode::Normal => {
let help_text = match app.view {
View::Dashboard => {
if app.feeds.is_empty() {
format!(
"{}: Add feed | {}: Theme | {}: Quit | {}: Quit | CTRL+C: Manage categories",
key_display(&KeyAction::AddFeed, &app.keybindings),
key_display(&KeyAction::ToggleTheme, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
} else {
format!(
"{}/{}: Navigate | {}: View | {}: Star | {}: Toggle read | {}: Mark all read | {}: Preview | {}: Add | {}: Refresh | {}: Filter | {}: Search | {}: Help | {}: Quit",
key_display(&KeyAction::MoveUp, &app.keybindings),
key_display(&KeyAction::MoveDown, &app.keybindings),
key_display(&KeyAction::Select, &app.keybindings),
key_display(&KeyAction::ToggleStar, &app.keybindings),
key_display(&KeyAction::ToggleRead, &app.keybindings),
key_display(&KeyAction::MarkAllRead, &app.keybindings),
key_display(&KeyAction::TogglePreview, &app.keybindings),
key_display(&KeyAction::AddFeed, &app.keybindings),
key_display(&KeyAction::Refresh, &app.keybindings),
key_display(&KeyAction::OpenFilter, &app.keybindings),
key_display(&KeyAction::OpenSearch, &app.keybindings),
key_display(&KeyAction::Help, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
)
}
}
View::FeedList => {
if app.feeds.is_empty() && app.categories.is_empty() {
format!(
"{}: Add feed | {}: Theme | {}: Back | {}: Quit | TAB: Dashboard | CTRL+C: Categories",
key_display(&KeyAction::AddFeed, &app.keybindings),
key_display(&KeyAction::ToggleTheme, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
} else {
format!(
"{}/{}: Navigate | {}: Open | Space: Expand/Collapse | d: Delete | c: Category | {}: Mark read | {}: Add | {}: Help | {}: Back",
key_display(&KeyAction::MoveUp, &app.keybindings),
key_display(&KeyAction::MoveDown, &app.keybindings),
key_display(&KeyAction::Select, &app.keybindings),
key_display(&KeyAction::MarkAllRead, &app.keybindings),
key_display(&KeyAction::AddFeed, &app.keybindings),
key_display(&KeyAction::Help, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
)
}
}
View::CategoryManagement => {
format!(
"n: New category | e: Edit | d: Delete | {}: Toggle feeds | c: Add selected feed | {}: Theme | ESC/{}: Back",
key_display(&KeyAction::ToggleExpand, &app.keybindings),
key_display(&KeyAction::ToggleTheme, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
)
}
View::FeedItems => {
format!(
"{}/{}: Navigate | {}: View | {}: Star | {}: Toggle read | {}: Mark all read | {}: Open | {}: Search | {}: Theme | {}: Back | {}: Quit",
key_display(&KeyAction::MoveUp, &app.keybindings),
key_display(&KeyAction::MoveDown, &app.keybindings),
key_display(&KeyAction::Select, &app.keybindings),
key_display(&KeyAction::ToggleStar, &app.keybindings),
key_display(&KeyAction::ToggleRead, &app.keybindings),
key_display(&KeyAction::MarkAllRead, &app.keybindings),
key_display(&KeyAction::OpenInBrowser, &app.keybindings),
key_display(&KeyAction::OpenSearch, &app.keybindings),
key_display(&KeyAction::ToggleTheme, &app.keybindings),
key_display(&KeyAction::Back, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
}
View::FeedItemDetail => {
format!(
"{}/{}: Scroll | {}/{}: Fast scroll | {}: Open | {}: Star | {}: Toggle read | {}: Links | {}: Search | {}: Theme | {}: Back | {}: Quit",
key_display(&KeyAction::MoveUp, &app.keybindings),
key_display(&KeyAction::MoveDown, &app.keybindings),
key_display(&KeyAction::PageUp, &app.keybindings),
key_display(&KeyAction::PageDown, &app.keybindings),
key_display(&KeyAction::OpenInBrowser, &app.keybindings),
key_display(&KeyAction::ToggleStar, &app.keybindings),
key_display(&KeyAction::ToggleRead, &app.keybindings),
key_display(&KeyAction::ExtractLinks, &app.keybindings),
key_display(&KeyAction::OpenSearch, &app.keybindings),
key_display(&KeyAction::ToggleTheme, &app.keybindings),
key_display(&KeyAction::Back, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
}
View::Starred => {
format!(
"{}/{}: Navigate | {}: View | {}: Unstar | {}: Toggle read | {}: Mark all read | {}: Open | {}: Search | {}: Back | {}: Quit",
key_display(&KeyAction::MoveUp, &app.keybindings),
key_display(&KeyAction::MoveDown, &app.keybindings),
key_display(&KeyAction::Select, &app.keybindings),
key_display(&KeyAction::ToggleStar, &app.keybindings),
key_display(&KeyAction::ToggleRead, &app.keybindings),
key_display(&KeyAction::MarkAllRead, &app.keybindings),
key_display(&KeyAction::OpenInBrowser, &app.keybindings),
key_display(&KeyAction::OpenSearch, &app.keybindings),
key_display(&KeyAction::Quit, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
}
View::Summary => {
format!(
"Press any key to continue to Dashboard | {}: Back | {}: Quit",
key_display(&KeyAction::Quit, &app.keybindings),
key_display(&KeyAction::ForceQuit, &app.keybindings),
)
}
};
(help_text, Style::default().fg(colors.text))
}
InputMode::InsertUrl => (
"Enter feed URL (e.g., https://news.ycombinator.com/rss)".to_string(),
Style::default().fg(colors.highlight),
),
InputMode::SearchMode => (
"Type to search (results update live) | ENTER: keep results | ESC: cancel".to_string(),
Style::default().fg(colors.highlight),
),
InputMode::FilterMode => ("".to_string(), Style::default().fg(colors.muted)),
InputMode::CategoryNameInput => ("".to_string(), Style::default().fg(colors.muted)),
InputMode::SelectDiscoveredFeed => (
"j/k: Navigate | Enter: Select feed | Esc: Cancel".to_string(),
Style::default().fg(colors.highlight),
),
};
if matches!(app.input_mode, InputMode::Normal) {
let parts: Vec<&str> = help_text.split('|').collect();
let mut spans = Vec::new();
for (idx, part) in parts.iter().enumerate() {
let trimmed = part.trim();
if let Some(pos) = trimmed.find(':') {
let (key, desc) = trimmed.split_at(pos + 1);
spans.push(Span::styled(
key,
Style::default()
.fg(colors.highlight)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(desc, Style::default().fg(colors.text)));
} else {
spans.push(Span::styled(trimmed, Style::default().fg(colors.text)));
}
if idx < parts.len() - 1 {
spans.push(Span::styled(" | ", Style::default().fg(colors.border)));
}
}
let command_icon = if colors.border_normal == BorderType::Double {
"◈" } else {
"💡" };
let help = Paragraph::new(Line::from(spans))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(colors.border_normal)
.border_style(Style::default().fg(colors.border))
.style(Style::default().bg(colors.surface))
.title(format!(" {} Commands ", command_icon))
.title_alignment(Alignment::Center)
.padding(Padding::new(1, 1, 0, 0)),
);
f.render_widget(help, area);
}
}
fn render_compact_help_bar<B: Backend>(
f: &mut Frame<B>,
app: &App,
area: Rect,
colors: &ColorScheme,
) {
if !matches!(app.input_mode, InputMode::Normal) {
return;
}
let kd = |action: &KeyAction| key_display(action, &app.keybindings);
let help_text = match app.view {
View::Dashboard => format!(
"{}:quit {}:add {}:refresh {}:search {}:filter {}:preview {}:help",
kd(&KeyAction::Quit),
kd(&KeyAction::AddFeed),
kd(&KeyAction::Refresh),
kd(&KeyAction::OpenSearch),
kd(&KeyAction::OpenFilter),
kd(&KeyAction::TogglePreview),
kd(&KeyAction::Help),
),
View::FeedList => format!(
"{}:back {}:add {}:open {}:expand d:del c:category {}:read",
kd(&KeyAction::Quit),
kd(&KeyAction::AddFeed),
kd(&KeyAction::Select),
kd(&KeyAction::ToggleExpand),
kd(&KeyAction::MarkAllRead),
),
View::FeedItems => format!(
"{}:back {}:view {}:open {}:star {}:search",
kd(&KeyAction::Quit),
kd(&KeyAction::Select),
kd(&KeyAction::OpenInBrowser),
kd(&KeyAction::ToggleStar),
kd(&KeyAction::OpenSearch),
),
View::FeedItemDetail => format!(
"{}:back {}:scroll {}:open {}:star {}:read",
kd(&KeyAction::Quit),
kd(&KeyAction::MoveDown),
kd(&KeyAction::OpenInBrowser),
kd(&KeyAction::ToggleStar),
kd(&KeyAction::ToggleRead),
),
View::CategoryManagement => format!("{}:back n:new e:edit d:del", kd(&KeyAction::Quit),),
View::Starred => format!(
"{}:back {}:view {}:unstar {}:open",
kd(&KeyAction::Quit),
kd(&KeyAction::Select),
kd(&KeyAction::ToggleStar),
kd(&KeyAction::OpenInBrowser),
),
View::Summary => "any key:continue".to_string(),
};
let spans: Vec<Span> = help_text
.split(' ')
.enumerate()
.flat_map(|(i, part)| {
let mut result = Vec::new();
if i > 0 {
result.push(Span::styled(" ", Style::default().fg(colors.border)));
}
if let Some(pos) = part.find(':') {
let (key, desc) = part.split_at(pos + 1);
result.push(Span::styled(
key,
Style::default()
.fg(colors.highlight)
.add_modifier(Modifier::BOLD),
));
result.push(Span::styled(desc, Style::default().fg(colors.text)));
} else {
result.push(Span::styled(part, Style::default().fg(colors.text)));
}
result
})
.collect();
let help = Paragraph::new(Line::from(spans))
.alignment(Alignment::Center)
.style(Style::default().bg(colors.surface));
f.render_widget(help, area);
}