diff --git a/README.md b/README.md index d076f39..0d4b515 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ # Todo -- Fix parser, doesnt work for https://blog.codinghorror.com/rss/ +- Fix parser + - [ ] https://blog.codinghorror.com/rss/ - Add support for Atom feeds - Add/remove from TUI - Sync diff --git a/src/ui.rs b/src/ui.rs index 68734e0..760af30 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,10 @@ +pub mod actions; +pub mod articles; +pub mod channels; +pub mod controls; +pub mod debug; +pub mod title; + use std::io::Stdout; use crate::{ @@ -6,13 +13,18 @@ use crate::{ error::{Result, TrsError}, persistence::RssChannelD, }; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use articles::ArticlesWidget; +use channels::ChannelsWidget; +use controls::ControlsWidget; +use crossterm::event; +use debug::DebugWidget; use ratatui::{ prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders}, }; +use title::TitleWidget; -struct AppState { +pub struct AppState { exit: bool, debug_enabled: bool, debug: bool, @@ -20,26 +32,24 @@ struct AppState { focussed: FocussedPane, highlighted_channel: Option, highlighted_article: Option, + last_action: Option, } #[derive(Clone, Copy, PartialEq)] -enum FocussedPane { +pub enum FocussedPane { Channels, Articles, } -impl AppState { - fn is_debug_mode(&self) -> bool { - self.debug_enabled && self.debug - } -} - -enum TrsEvent { +#[derive(Debug, Clone, PartialEq)] +pub enum UiAction { None, - FocusEntryDown, + FocusPaneRight, + FocusPaneLeft, + FocusPaneUp, + FocusPaneDown, FocusEntryUp, - FocusArticles, - FocusChannels, + FocusEntryDown, ToggleDebug, OpenArticle, Exit, @@ -55,6 +65,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> { focussed: FocussedPane::Channels, highlighted_article: None, highlighted_channel: None, + last_action: None, }; let channels = commands::list_channels(ctx, &ListChannelArgs { limit: None })?; @@ -62,9 +73,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> { loop { draw(&app_state, &mut terminal)?; - handle_events(&mut app_state)?; - if app_state.exit { break; } @@ -77,8 +86,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> { fn draw(app_state: &AppState, terminal: &mut Terminal>) -> Result<()> { terminal .draw(|f| { - let root_widget = RootWidget { state: &app_state }; - f.render_widget(root_widget, f.area()); + f.render_widget(AppStateWidget::new(app_state), f.area()); }) .map_err(|e| TrsError::TuiError(e))?; @@ -87,183 +95,25 @@ fn draw(app_state: &AppState, terminal: &mut Terminal>) fn handle_events(state: &mut AppState) -> Result<()> { let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?; - let event = parse_trs_event(state, raw_event); - match event { - TrsEvent::None => {} - TrsEvent::FocusEntryDown => match state.highlighted_channel { - Some(h) => match state.focussed { - FocussedPane::Channels => { - let max = state.channels.len() - 1; - state.highlighted_channel = Some(saturating_add(h, 1, max)); - match state.highlighted_article { - Some(a) => { - if let Some(c_a) = state.highlighted_channel { - if let Some(channel) = state.channels.get(c_a) { - let max_article = channel.articles.len() - 1; - state.highlighted_article = - Some(saturating_add(a, 0, max_article)); - } - } - } - None => {} - } - } - FocussedPane::Articles => { - if let Some(channel) = state.channels.get(h) { - match state.highlighted_article { - Some(a) => { - let max = channel.articles.len() - 1; - state.highlighted_article = Some(saturating_add(a, 1, max)); - } - None => { - if !channel.articles.is_empty() { - state.highlighted_article = Some(0); - } - } - } - } - } - }, - None => { - if let FocussedPane::Channels = state.focussed { - if !state.channels.is_empty() { - state.highlighted_channel = Some(0); - } - - match state.highlighted_article { - Some(a) => { - if let Some(c_a) = state.highlighted_channel { - if let Some(channel) = state.channels.get(c_a) { - let max_article = channel.articles.len() - 1; - state.highlighted_article = - Some(saturating_add(a, 0, max_article)); - } - } - } - None => {} - } - } - } - }, - TrsEvent::FocusEntryUp => match &mut state.highlighted_channel { - Some(h) => match state.focussed { - FocussedPane::Channels => { - state.highlighted_channel = Some(saturating_sub(*h, 1, 0)); - } - FocussedPane::Articles => { - if let Some(idx) = state.highlighted_article { - state.highlighted_article = Some(saturating_sub(idx, 1, 0)); - } - } - }, - None => {} - }, - TrsEvent::ToggleDebug => state.debug = !state.debug, - TrsEvent::Exit => state.exit = true, - TrsEvent::FocusArticles => state.focussed = FocussedPane::Articles, - TrsEvent::FocusChannels => state.focussed = FocussedPane::Channels, - TrsEvent::OpenArticle => { - if let Some(channel_idx) = state.highlighted_channel { - if let Some(article_idx) = state.highlighted_article { - if let Some(channel) = state.channels.get(channel_idx) { - if let Some(article) = channel.articles.get(article_idx) { - let open_res = open::that(&article.link); - if let Err(e) = open_res { - eprintln!("Failed to open article: {}", e); - } - } - } - } - } - } - }; - Ok(()) + let event = controls::parse_ui_action(raw_event); + state.last_action = Some(event.clone()); + actions::handle_action(state, event) } -fn parse_trs_event(state: &AppState, raw_event: Event) -> TrsEvent { - match raw_event { - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => match key_event.code { - KeyCode::Char('q') | KeyCode::Esc => TrsEvent::Exit, - KeyCode::Char('j') => TrsEvent::FocusEntryDown, - KeyCode::Char('l') => match state.focussed { - FocussedPane::Channels => TrsEvent::FocusArticles, - FocussedPane::Articles => TrsEvent::None, - }, - KeyCode::Char('h') => match state.focussed { - FocussedPane::Channels => TrsEvent::None, - FocussedPane::Articles => TrsEvent::FocusChannels, - }, - KeyCode::Char('k') => TrsEvent::FocusEntryUp, - KeyCode::Char('d') if state.debug_enabled => return TrsEvent::ToggleDebug, - KeyCode::Enter => return TrsEvent::OpenArticle, - _ => TrsEvent::None, - }, - _ => TrsEvent::None, +struct AppStateWidget<'a> { + app_state: &'a AppState, +} + +impl<'a> AppStateWidget<'a> { + pub fn new(app_state: &'a AppState) -> Self { + Self { app_state } } } -fn saturating_sub(num: usize, to_sub: usize, min: usize) -> usize { - if num < to_sub { - min - } else { - num - to_sub - } -} - -fn saturating_add(num: usize, to_add: usize, max: usize) -> usize { - if num + to_add > max { - max - } else { - num + to_add - } -} - -/// ------------------------------------------------------------------------ -/// Widgets -/// ------------------------------------------------------------------------ - -struct RootWidget<'a> { - state: &'a AppState, -} -impl<'a> RootWidget<'a> { - fn get_child_widget_style(&self, arg: &'a str, focussed: bool) -> Block<'a> { - let title = Line::from(arg) - .centered() - .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)); - - if focussed { - return Block::default() - .title_top(title) - .border_style(Style::default().fg(Color::Blue)) - .borders(Borders::ALL); - } - - Block::default() - .title_top(title) - .border_style(Style::default().fg(Color::DarkGray)) - } -} - -struct ChannelsWidget<'a> { - state: &'a AppState, -} - -struct ArticlesWidget<'a> { - state: &'a AppState, -} - -struct ControlsWidget; - -struct TitleWidget; - -struct DebugWidget<'a> { - state: &'a AppState, -} - -impl<'a> Widget for RootWidget<'a> { +impl<'a> Widget for AppStateWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let mut horizontal_constraints = vec![Constraint::Percentage(100)]; - if self.state.is_debug_mode() { + if is_debug_mode(self.app_state) { horizontal_constraints.push(Constraint::Percentage(20)); } @@ -275,9 +125,9 @@ impl<'a> Widget for RootWidget<'a> { .split(area) .to_vec(); let main_area = horizontal_areas[0]; - if self.state.is_debug_mode() { + if is_debug_mode(self.app_state) { let debug_area = horizontal_areas[1]; - draw_app_widget("Debug", &debug_area, buf, DebugWidget::new(self.state)); + draw_app_widget("Debug", &debug_area, buf, DebugWidget::new(self.app_state)); } // Define the main area layout @@ -304,18 +154,24 @@ impl<'a> Widget for RootWidget<'a> { let channels_area = child_widgets_areas[0]; draw_app_widget_styled( - self.get_child_widget_style("Channels", self.state.focussed == FocussedPane::Channels), + get_child_widget_style( + "Channels", + self.app_state.focussed == FocussedPane::Channels, + ), &channels_area, buf, - ChannelsWidget { state: self.state }, + ChannelsWidget::new(self.app_state), ); let articles_area = child_widgets_areas[1]; draw_app_widget_styled( - self.get_child_widget_style("Articles", self.state.focussed == FocussedPane::Articles), + get_child_widget_style( + "Articles", + self.app_state.focussed == FocussedPane::Articles, + ), &articles_area, buf, - ArticlesWidget { state: self.state }, + ArticlesWidget::new(self.app_state), ); // CONTROLS @@ -324,28 +180,6 @@ impl<'a> Widget for RootWidget<'a> { } } -impl Widget for TitleWidget { - fn render(self, area: Rect, buf: &mut Buffer) { - let title = "Terminal RSS Reader"; - let areas = Layout::default() - .constraints(Constraint::from_ratios([(1, 3), (1, 3), (1, 3)])) - .split(area) - .to_vec(); - - let para = Paragraph::new(title).alignment(Alignment::Center).style( - Style::default() - .fg(Color::Black) - .add_modifier(Modifier::BOLD), - ); - - para.render(areas[1], buf); - - Block::default() - .style(Style::default().bg(Color::LightCyan)) - .render(area, buf); - } -} - fn draw_app_widget(title: &'static str, area: &Rect, buffer: &mut Buffer, widget: T) where T: Widget, @@ -377,258 +211,23 @@ where widget.render(actual_area[0], buffer); } -impl<'a> Widget for ChannelsWidget<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let height_per_entry = 1; - let total_channels = area.height / height_per_entry; - let total_channels = self.state.channels.len().min(total_channels as usize); - let channel_rows = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - (0..total_channels) - .map(|_| Constraint::Length(height_per_entry)) - .collect::>(), - ) - .split(area) - .to_vec(); +fn get_child_widget_style<'a>(arg: &'a str, focussed: bool) -> Block<'a> { + let title = Line::from(arg) + .centered() + .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)); - for ((idx, row), channel) in channel_rows - .into_iter() - .enumerate() - .zip(&self.state.channels) - { - let current_highlighted = self - .state - .highlighted_channel - .filter(|h| *h == idx) - .is_some(); - let mut lines = Vec::new(); - let id = Span::styled( - format!("{:>3}. ", idx + 1), - get_channel_id_style(current_highlighted), - ); - - let title = Span::styled( - channel.title.clone(), - get_channel_title_style(current_highlighted), - ); - - lines.push(Line::from(vec![id, title])); - if let Some(article) = channel.articles.first() { - let pub_date_text = match article.pub_date { - Some(date) => format!("Last update: {}", date), - None => "".to_string(), - }; - let pub_date = Span::styled( - pub_date_text, - get_channel_pub_date_style(current_highlighted), - ); - lines.push(Line::from(vec![pub_date])); - } - - let para = Paragraph::new(lines) - .block(Block::default()) - .style(get_channel_list_item_block_style(current_highlighted)) - .alignment(Alignment::Left); - para.render(row, buf); - } + if focussed { + return Block::default() + .title_top(title) + .border_style(Style::default().fg(Color::Blue)) + .borders(Borders::ALL); } + + Block::default() + .title_top(title) + .border_style(Style::default().fg(Color::DarkGray)) } -impl<'a> Widget for ArticlesWidget<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let selected_channel = match self.state.highlighted_channel { - Some(c) => self.state.channels.get(c), - None => None, - }; - - let Some(channel) = selected_channel else { - let para = Paragraph::new("j/k to navigate channels, q to exit") - .block(Block::default().borders(Borders::NONE)) - .alignment(Alignment::Center); - para.render(area, buf); - return; - }; - - let count = channel.articles.len(); - let para = Paragraph::new(format!("{} ({} articles)", channel.title, count)).centered(); - para.render(area, buf); - - let height_per_entry = 1; - let total_articles = area.height / height_per_entry; - let total_articles = channel.articles.len().min(total_articles as usize); - let article_rows = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - (0..total_articles) - .map(|_| Constraint::Length(height_per_entry)) - .collect::>(), - ) - .split(area) - .to_vec(); - - for ((idx, row), article) in article_rows.into_iter().enumerate().zip(&channel.articles) { - let current_highlighted = self - .state - .highlighted_article - .filter(|h| *h == idx) - .is_some(); - let mut lines = Vec::new(); - let id = Span::styled( - format!("{:>3}. ", idx + 1), - get_channel_id_style(current_highlighted), - ); - let title = Span::styled( - article.title.clone(), - get_channel_title_style(current_highlighted), - ); - - lines.push(Line::from(vec![id, title])); - if let Some(pub_date) = article.pub_date { - let pub_date_text = format!("Published: {}", pub_date); - let pub_date_span = Span::styled(pub_date_text, get_channel_pub_date_style(false)); - lines.push(Line::from(vec![pub_date_span])); - } - - let para = Paragraph::new(lines) - .block(Block::default()) - .style(get_channel_list_item_block_style(current_highlighted)) - .alignment(Alignment::Left); - para.render(row, buf); - } - } -} - -macro_rules! control { - ($key:literal) => { - Span::styled( - $key, - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ) - }; -} - -macro_rules! description { - ($key:literal) => { - Span::raw($key) - }; -} - -impl Widget for ControlsWidget { - fn render(self, area: Rect, buf: &mut Buffer) { - let controls_text = Line::from(vec![ - control!("j/k"), - description!(" to navigate up/down, "), - control!("h/l"), - description!(" to switch between channels and articles, "), - control!("q"), - description!(" to exit"), - ]) - .style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ); - let para = Paragraph::new(controls_text) - .block(Block::default().borders(Borders::NONE)) - .alignment(Alignment::Center); - para.render(area, buf); - } -} - -impl<'a> Widget for DebugWidget<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let mut lines = Vec::new(); - lines.push(format!("channels: {}", self.state.channels.len())); - lines.push(format!("highlighted: {:?}", self.state.highlighted_channel)); - if let Some(h) = self.state.highlighted_channel { - for channel in &self.state.channels { - if channel.id as usize == h { - lines.push(format!( - "highlighted channel: ({},{},{},{:?})", - channel.id, channel.title, channel.link, channel.last_update - )); - - if let Some(article) = channel.articles.first() { - lines.push(format!( - "first article: ({},{},{},{:?})", - article.id, article.title, article.link, article.last_update - )); - } - } - } - } - - if let Some(h) = self.state.highlighted_article { - lines.push(format!("highlighted article: {}", h)); - } - - let line_areas = Layout::default() - .direction(Direction::Vertical) - .constraints( - lines - .iter() - .map(|_| Constraint::Length(5)) - .collect::>(), - ) - .split(area) - .to_vec(); - - let mut idx = 0; - for debug_line in lines { - let para = Paragraph::new(debug_line) - .wrap(Wrap::default()) - .block(Block::default().borders(Borders::BOTTOM)); - para.render(line_areas[idx], buf); - idx = idx + 1; - } - } -} - -impl<'a> DebugWidget<'a> { - fn new(state: &'a AppState) -> Self { - DebugWidget { state } - } -} - -fn get_channel_title_style(highlighted: bool) -> Style { - if highlighted { - Style::default() - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - } -} - -fn get_channel_id_style(highlighted: bool) -> Style { - if highlighted { - Style::default() - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - } -} - -fn get_channel_list_item_block_style(highlighted: bool) -> Style { - if highlighted { - Style::default() - .bg(Color::LightYellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - } -} - -fn get_channel_pub_date_style(highlighted: bool) -> Style { - if highlighted { - Style::default() - .fg(Color::Black) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - } +fn is_debug_mode(app_state: &AppState) -> bool { + app_state.debug_enabled && app_state.debug } diff --git a/src/ui/actions.rs b/src/ui/actions.rs new file mode 100644 index 0000000..f6232b5 --- /dev/null +++ b/src/ui/actions.rs @@ -0,0 +1,142 @@ +use crate::{ + error::TrsError, + persistence::{RssArticleD, RssChannelD}, +}; + +use super::{AppState, FocussedPane, UiAction}; + +pub fn handle_action( + app_state: &mut AppState, + event: UiAction, +) -> std::result::Result<(), TrsError> { + match event { + UiAction::None => {} + UiAction::FocusPaneUp => {} + UiAction::FocusPaneDown => {} + UiAction::FocusPaneLeft => app_state.focussed = FocussedPane::Channels, + UiAction::FocusPaneRight => app_state.focussed = FocussedPane::Articles, + UiAction::ToggleDebug => toggle_debug(app_state), + UiAction::Exit => app_state.exit = true, + UiAction::FocusEntryDown => focus_entry_down(app_state), + UiAction::FocusEntryUp => focus_entry_up(app_state), + UiAction::OpenArticle => { + if let Some(channel_idx) = app_state.highlighted_channel { + if let Some(article_idx) = app_state.highlighted_article { + if let Some(channel) = app_state.channels.get(channel_idx) { + if let Some(article) = channel.articles.get(article_idx) { + let open_res = open::that(&article.link); + if let Err(e) = open_res { + eprintln!("Failed to open article: {}", e); + } + } + } + } + } + } + }; + Ok(()) +} + +fn saturating_add(num: usize, to_add: usize, max: usize) -> usize { + if num + to_add > max { + max + } else { + num + to_add + } +} + +fn min(arg: usize, max_channel_idx: usize) -> usize { + if arg < max_channel_idx { + arg + } else { + max_channel_idx + } +} + +fn toggle_debug(app_state: &mut AppState) { + app_state.debug = !app_state.debug; +} + +fn get_highlighted_channel<'a>(app_state: &'a AppState) -> Option<&'a RssChannelD> { + app_state + .highlighted_channel + .and_then(|idx| app_state.channels.get(idx).or_else(|| None)) +} + +fn focus_entry_up(app_state: &mut AppState) { + match app_state.focussed { + FocussedPane::Channels => decrement_highlighted_channel_idx(app_state), + FocussedPane::Articles => decrement_highlighted_article_idx(app_state), + } + .unwrap_or(false); +} + +fn focus_entry_down(app_state: &mut AppState) { + match app_state.focussed { + FocussedPane::Channels => increment_highlighted_channel_idx(app_state), + FocussedPane::Articles => increment_highlighted_article_idx(app_state), + } + .unwrap_or(false); +} + +fn increment_highlighted_channel_idx(app_state: &mut AppState) -> Option { + let max_channel_idx = app_state.channels.len().saturating_sub(1); + if max_channel_idx == 0 { + app_state.highlighted_channel = None; + return Some(false); + } + + app_state.highlighted_channel = app_state + .highlighted_channel + .map(|idx| saturating_add(idx, 1, max_channel_idx)) + .or_else(|| Some(0)); + + update_highligted_article(app_state) +} + +fn decrement_highlighted_channel_idx(app_state: &mut AppState) -> Option { + app_state.highlighted_channel = app_state + .highlighted_channel + .map(|idx| idx.saturating_sub(1)); + + update_highligted_article(app_state) +} + +// When changing the channel, update the idx of the article to be within the range +fn update_highligted_article(app_state: &mut AppState) -> Option { + let hi_channel_articles_max = get_highlighted_channel(app_state)?.articles.len(); + let hi_article_idx = app_state.highlighted_article?; + let max_article_idx = hi_channel_articles_max.saturating_sub(1); + + if max_article_idx == 0 { + app_state.highlighted_article = None; + } else if hi_article_idx > max_article_idx { + app_state.highlighted_article = Some(max_article_idx); + } else { + app_state.highlighted_article = Some(hi_article_idx); + } + + Some(true) +} + +fn increment_highlighted_article_idx(app_state: &mut AppState) -> Option { + let hi_article_max_idx = get_highlighted_channel(app_state)? + .articles + .len() + .saturating_sub(1); + + app_state.highlighted_article = app_state + .highlighted_article + .map(|idx| saturating_add(idx, 1, hi_article_max_idx)) + .or_else(|| Some(min(0, hi_article_max_idx))); + + Some(true) +} + +fn decrement_highlighted_article_idx(app_state: &mut AppState) -> Option { + app_state.highlighted_article = app_state + .highlighted_article + .map(|idx| idx.saturating_sub(1)); + + Some(true) +} diff --git a/src/ui/articles.rs b/src/ui/articles.rs new file mode 100644 index 0000000..68bfe99 --- /dev/null +++ b/src/ui/articles.rs @@ -0,0 +1,124 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +use super::AppState; + +pub struct ArticlesWidget<'a> { + state: &'a AppState, +} + +impl<'a> ArticlesWidget<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } +} + +impl<'a> Widget for ArticlesWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let selected_channel = match self.state.highlighted_channel { + Some(c) => self.state.channels.get(c), + None => None, + }; + + let Some(channel) = selected_channel else { + let para = Paragraph::new("j/k to navigate channels, q to exit") + .block(Block::default().borders(Borders::NONE)) + .alignment(Alignment::Center); + para.render(area, buf); + return; + }; + + let count = channel.articles.len(); + let para = Paragraph::new(format!("{} ({} articles)", channel.title, count)).centered(); + para.render(area, buf); + + let height_per_entry = 1; + let total_articles = area.height / height_per_entry; + let total_articles = channel.articles.len().min(total_articles as usize); + let article_rows = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + (0..total_articles) + .map(|_| Constraint::Length(height_per_entry)) + .collect::>(), + ) + .split(area) + .to_vec(); + + for ((idx, row), article) in article_rows.into_iter().enumerate().zip(&channel.articles) { + let current_highlighted = self + .state + .highlighted_article + .filter(|h| *h == idx) + .is_some(); + let mut lines = Vec::new(); + let id = Span::styled( + format!("{:>3}. ", idx + 1), + get_channel_id_style(current_highlighted), + ); + let title = Span::styled( + article.title.clone(), + get_channel_title_style(current_highlighted), + ); + + lines.push(Line::from(vec![id, title])); + if let Some(pub_date) = article.pub_date { + let pub_date_text = format!("Published: {}", pub_date); + let pub_date_span = Span::styled(pub_date_text, get_channel_pub_date_style(false)); + lines.push(Line::from(vec![pub_date_span])); + } + + let para = Paragraph::new(lines) + .block(Block::default()) + .style(get_channel_list_item_block_style(current_highlighted)) + .alignment(Alignment::Left); + para.render(row, buf); + } + } +} + +fn get_channel_id_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_list_item_block_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .bg(Color::LightYellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_pub_date_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_title_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} diff --git a/src/ui/channels.rs b/src/ui/channels.rs new file mode 100644 index 0000000..5933b74 --- /dev/null +++ b/src/ui/channels.rs @@ -0,0 +1,118 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph, Widget}, +}; + +use super::AppState; + +pub struct ChannelsWidget<'a> { + state: &'a AppState, +} + +impl<'a> ChannelsWidget<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } +} + +impl<'a> Widget for ChannelsWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let height_per_entry = 1; + let total_channels = area.height / height_per_entry; + let total_channels = self.state.channels.len().min(total_channels as usize); + let channel_rows = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + (0..total_channels) + .map(|_| Constraint::Length(height_per_entry)) + .collect::>(), + ) + .split(area) + .to_vec(); + + for ((idx, row), channel) in channel_rows + .into_iter() + .enumerate() + .zip(&self.state.channels) + { + let current_highlighted = self + .state + .highlighted_channel + .filter(|h| *h == idx) + .is_some(); + let mut lines = Vec::new(); + let id = Span::styled( + format!("{:>3}. ", idx + 1), + get_channel_id_style(current_highlighted), + ); + + let title = Span::styled( + channel.title.clone(), + get_channel_title_style(current_highlighted), + ); + + lines.push(Line::from(vec![id, title])); + if let Some(article) = channel.articles.first() { + let pub_date_text = match article.pub_date { + Some(date) => format!("Last update: {}", date), + None => "".to_string(), + }; + let pub_date = Span::styled( + pub_date_text, + get_channel_pub_date_style(current_highlighted), + ); + lines.push(Line::from(vec![pub_date])); + } + + let para = Paragraph::new(lines) + .block(Block::default()) + .style(get_channel_list_item_block_style(current_highlighted)) + .alignment(Alignment::Left); + para.render(row, buf); + } + } +} + +fn get_channel_id_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_list_item_block_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .bg(Color::LightYellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_pub_date_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +fn get_channel_title_style(highlighted: bool) -> Style { + if highlighted { + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} diff --git a/src/ui/controls.rs b/src/ui/controls.rs new file mode 100644 index 0000000..b0394b4 --- /dev/null +++ b/src/ui/controls.rs @@ -0,0 +1,83 @@ +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget}, +}; + +use super::UiAction; + +pub struct ControlsWidget; + +pub fn parse_ui_action(raw_event: Event) -> UiAction { + match raw_event { + Event::Key(key_event) => { + if key_event.kind != KeyEventKind::Press { + return UiAction::None; + } + + if key_event.modifiers == KeyModifiers::CONTROL { + return match key_event.code { + KeyCode::Char('p') => UiAction::FocusEntryUp, + KeyCode::Char('n') => UiAction::FocusEntryDown, + KeyCode::Char('l') => UiAction::FocusPaneRight, + KeyCode::Char('h') => UiAction::FocusPaneLeft, + _ => UiAction::None, + }; + } + + if key_event.modifiers == KeyModifiers::NONE { + return match key_event.code { + KeyCode::Char('l') => UiAction::FocusPaneRight, + KeyCode::Char('h') => UiAction::FocusPaneLeft, + KeyCode::Char('d') => UiAction::ToggleDebug, + KeyCode::Char('q') => UiAction::Exit, + KeyCode::Enter => UiAction::OpenArticle, + _ => UiAction::None, + }; + } + + UiAction::None + } + _ => UiAction::None, + } +} + +macro_rules! control { + ($key:literal) => { + Span::styled( + $key, + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + }; +} + +macro_rules! description { + ($key:literal) => { + Span::raw($key) + }; +} + +impl Widget for ControlsWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + let controls_text = Line::from(vec![ + control!("j/k"), + description!(" to navigate up/down, "), + control!("h/l"), + description!(" to switch between channels and articles, "), + control!("q"), + description!(" to exit"), + ]) + .style( + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ); + let para = Paragraph::new(controls_text) + .block(Block::default().borders(Borders::NONE)) + .alignment(Alignment::Center); + para.render(area, buf); + } +} diff --git a/src/ui/debug.rs b/src/ui/debug.rs new file mode 100644 index 0000000..7cd8ba1 --- /dev/null +++ b/src/ui/debug.rs @@ -0,0 +1,67 @@ +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Paragraph, Widget, Wrap}, +}; + +use super::AppState; + +pub struct DebugWidget<'a> { + state: &'a AppState, +} + +impl<'a> DebugWidget<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } +} + +impl<'a> Widget for DebugWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut lines = Vec::new(); + lines.push(format!("last action: {:?}", self.state.last_action)); + lines.push(format!("channels: {}", self.state.channels.len())); + lines.push(format!("highlighted: {:?}", self.state.highlighted_channel)); + if let Some(h) = self.state.highlighted_channel { + for channel in &self.state.channels { + if channel.id as usize == h { + lines.push(format!( + "highlighted channel: ({},{},{},{:?})", + channel.id, channel.title, channel.link, channel.last_update + )); + + if let Some(article) = channel.articles.first() { + lines.push(format!( + "first article: ({},{},{},{:?})", + article.id, article.title, article.link, article.last_update + )); + } + } + } + } + + if let Some(h) = self.state.highlighted_article { + lines.push(format!("highlighted article: {}", h)); + } + + let line_areas = Layout::default() + .direction(Direction::Vertical) + .constraints( + lines + .iter() + .map(|_| Constraint::Length(5)) + .collect::>(), + ) + .split(area) + .to_vec(); + + let mut idx = 0; + for debug_line in lines { + let para = Paragraph::new(debug_line) + .wrap(Wrap::default()) + .block(Block::default().borders(Borders::BOTTOM)); + para.render(line_areas[idx], buf); + idx = idx + 1; + } + } +} diff --git a/src/ui/title.rs b/src/ui/title.rs new file mode 100644 index 0000000..4527863 --- /dev/null +++ b/src/ui/title.rs @@ -0,0 +1,30 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Paragraph, Widget}, +}; + +pub struct TitleWidget; + +impl Widget for TitleWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + let title = "Terminal RSS Reader"; + let areas = Layout::default() + .constraints(Constraint::from_ratios([(1, 3), (1, 3), (1, 3)])) + .split(area) + .to_vec(); + + let para = Paragraph::new(title).alignment(Alignment::Center).style( + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ); + + para.render(areas[1], buf); + + Block::default() + .style(Style::default().bg(Color::LightCyan)) + .render(area, buf); + } +}