refactor
This commit is contained in:
parent
549ebe2f1d
commit
7f759e4d26
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
# Todo
|
# Todo
|
||||||
|
|
||||||
- Fix parser, doesnt work for https://blog.codinghorror.com/rss/
|
- Fix parser
|
||||||
|
- [ ] https://blog.codinghorror.com/rss/
|
||||||
- Add support for Atom feeds
|
- Add support for Atom feeds
|
||||||
- Add/remove from TUI
|
- Add/remove from TUI
|
||||||
- Sync
|
- Sync
|
||||||
|
529
src/ui.rs
529
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 std::io::Stdout;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -6,13 +13,18 @@ use crate::{
|
|||||||
error::{Result, TrsError},
|
error::{Result, TrsError},
|
||||||
persistence::RssChannelD,
|
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::{
|
use ratatui::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
widgets::{Block, Borders, Paragraph, Wrap},
|
widgets::{Block, Borders},
|
||||||
};
|
};
|
||||||
|
use title::TitleWidget;
|
||||||
|
|
||||||
struct AppState {
|
pub struct AppState {
|
||||||
exit: bool,
|
exit: bool,
|
||||||
debug_enabled: bool,
|
debug_enabled: bool,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
@ -20,26 +32,24 @@ struct AppState {
|
|||||||
focussed: FocussedPane,
|
focussed: FocussedPane,
|
||||||
highlighted_channel: Option<usize>,
|
highlighted_channel: Option<usize>,
|
||||||
highlighted_article: Option<usize>,
|
highlighted_article: Option<usize>,
|
||||||
|
last_action: Option<UiAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
enum FocussedPane {
|
pub enum FocussedPane {
|
||||||
Channels,
|
Channels,
|
||||||
Articles,
|
Articles,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
fn is_debug_mode(&self) -> bool {
|
pub enum UiAction {
|
||||||
self.debug_enabled && self.debug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TrsEvent {
|
|
||||||
None,
|
None,
|
||||||
FocusEntryDown,
|
FocusPaneRight,
|
||||||
|
FocusPaneLeft,
|
||||||
|
FocusPaneUp,
|
||||||
|
FocusPaneDown,
|
||||||
FocusEntryUp,
|
FocusEntryUp,
|
||||||
FocusArticles,
|
FocusEntryDown,
|
||||||
FocusChannels,
|
|
||||||
ToggleDebug,
|
ToggleDebug,
|
||||||
OpenArticle,
|
OpenArticle,
|
||||||
Exit,
|
Exit,
|
||||||
@ -55,6 +65,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
|
|||||||
focussed: FocussedPane::Channels,
|
focussed: FocussedPane::Channels,
|
||||||
highlighted_article: None,
|
highlighted_article: None,
|
||||||
highlighted_channel: None,
|
highlighted_channel: None,
|
||||||
|
last_action: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let channels = commands::list_channels(ctx, &ListChannelArgs { limit: None })?;
|
let channels = commands::list_channels(ctx, &ListChannelArgs { limit: None })?;
|
||||||
@ -62,9 +73,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
draw(&app_state, &mut terminal)?;
|
draw(&app_state, &mut terminal)?;
|
||||||
|
|
||||||
handle_events(&mut app_state)?;
|
handle_events(&mut app_state)?;
|
||||||
|
|
||||||
if app_state.exit {
|
if app_state.exit {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -77,8 +86,7 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
|
|||||||
fn draw(app_state: &AppState, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
fn draw(app_state: &AppState, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| {
|
.draw(|f| {
|
||||||
let root_widget = RootWidget { state: &app_state };
|
f.render_widget(AppStateWidget::new(app_state), f.area());
|
||||||
f.render_widget(root_widget, f.area());
|
|
||||||
})
|
})
|
||||||
.map_err(|e| TrsError::TuiError(e))?;
|
.map_err(|e| TrsError::TuiError(e))?;
|
||||||
|
|
||||||
@ -87,183 +95,25 @@ fn draw(app_state: &AppState, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
|||||||
|
|
||||||
fn handle_events(state: &mut AppState) -> Result<()> {
|
fn handle_events(state: &mut AppState) -> Result<()> {
|
||||||
let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?;
|
let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?;
|
||||||
let event = parse_trs_event(state, raw_event);
|
let event = controls::parse_ui_action(raw_event);
|
||||||
match event {
|
state.last_action = Some(event.clone());
|
||||||
TrsEvent::None => {}
|
actions::handle_action(state, event)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_trs_event(state: &AppState, raw_event: Event) -> TrsEvent {
|
struct AppStateWidget<'a> {
|
||||||
match raw_event {
|
app_state: &'a AppState,
|
||||||
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,
|
impl<'a> AppStateWidget<'a> {
|
||||||
KeyCode::Char('l') => match state.focussed {
|
pub fn new(app_state: &'a AppState) -> Self {
|
||||||
FocussedPane::Channels => TrsEvent::FocusArticles,
|
Self { app_state }
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saturating_sub(num: usize, to_sub: usize, min: usize) -> usize {
|
impl<'a> Widget for AppStateWidget<'a> {
|
||||||
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> {
|
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
let mut horizontal_constraints = vec![Constraint::Percentage(100)];
|
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));
|
horizontal_constraints.push(Constraint::Percentage(20));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,9 +125,9 @@ impl<'a> Widget for RootWidget<'a> {
|
|||||||
.split(area)
|
.split(area)
|
||||||
.to_vec();
|
.to_vec();
|
||||||
let main_area = horizontal_areas[0];
|
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];
|
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
|
// Define the main area layout
|
||||||
@ -304,18 +154,24 @@ impl<'a> Widget for RootWidget<'a> {
|
|||||||
|
|
||||||
let channels_area = child_widgets_areas[0];
|
let channels_area = child_widgets_areas[0];
|
||||||
draw_app_widget_styled(
|
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,
|
&channels_area,
|
||||||
buf,
|
buf,
|
||||||
ChannelsWidget { state: self.state },
|
ChannelsWidget::new(self.app_state),
|
||||||
);
|
);
|
||||||
|
|
||||||
let articles_area = child_widgets_areas[1];
|
let articles_area = child_widgets_areas[1];
|
||||||
draw_app_widget_styled(
|
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,
|
&articles_area,
|
||||||
buf,
|
buf,
|
||||||
ArticlesWidget { state: self.state },
|
ArticlesWidget::new(self.app_state),
|
||||||
);
|
);
|
||||||
|
|
||||||
// CONTROLS
|
// 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<T>(title: &'static str, area: &Rect, buffer: &mut Buffer, widget: T)
|
fn draw_app_widget<T>(title: &'static str, area: &Rect, buffer: &mut Buffer, widget: T)
|
||||||
where
|
where
|
||||||
T: Widget,
|
T: Widget,
|
||||||
@ -377,258 +211,23 @@ where
|
|||||||
widget.render(actual_area[0], buffer);
|
widget.render(actual_area[0], buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Widget for ChannelsWidget<'a> {
|
fn get_child_widget_style<'a>(arg: &'a str, focussed: bool) -> Block<'a> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
let title = Line::from(arg)
|
||||||
let height_per_entry = 1;
|
.centered()
|
||||||
let total_channels = area.height / height_per_entry;
|
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));
|
||||||
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::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.split(area)
|
|
||||||
.to_vec();
|
|
||||||
|
|
||||||
for ((idx, row), channel) in channel_rows
|
if focussed {
|
||||||
.into_iter()
|
return Block::default()
|
||||||
.enumerate()
|
.title_top(title)
|
||||||
.zip(&self.state.channels)
|
.border_style(Style::default().fg(Color::Blue))
|
||||||
{
|
.borders(Borders::ALL);
|
||||||
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::default()
|
||||||
.block(Block::default())
|
.title_top(title)
|
||||||
.style(get_channel_list_item_block_style(current_highlighted))
|
.border_style(Style::default().fg(Color::DarkGray))
|
||||||
.alignment(Alignment::Left);
|
|
||||||
para.render(row, buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Widget for ArticlesWidget<'a> {
|
fn is_debug_mode(app_state: &AppState) -> bool {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
app_state.debug_enabled && app_state.debug
|
||||||
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::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.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::<Vec<Constraint>>(),
|
|
||||||
)
|
|
||||||
.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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
142
src/ui/actions.rs
Normal file
142
src/ui/actions.rs
Normal file
@ -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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
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<bool> {
|
||||||
|
app_state.highlighted_article = app_state
|
||||||
|
.highlighted_article
|
||||||
|
.map(|idx| idx.saturating_sub(1));
|
||||||
|
|
||||||
|
Some(true)
|
||||||
|
}
|
124
src/ui/articles.rs
Normal file
124
src/ui/articles.rs
Normal file
@ -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::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
118
src/ui/channels.rs
Normal file
118
src/ui/channels.rs
Normal file
@ -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::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
83
src/ui/controls.rs
Normal file
83
src/ui/controls.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
67
src/ui/debug.rs
Normal file
67
src/ui/debug.rs
Normal file
@ -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::<Vec<Constraint>>(),
|
||||||
|
)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
src/ui/title.rs
Normal file
30
src/ui/title.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user