add/remove channel, toggle read status

This commit is contained in:
cool-mist 2025-08-03 20:18:12 +05:30
parent d43996d049
commit 2a82e3bc66
10 changed files with 371 additions and 70 deletions

View File

@ -6,10 +6,18 @@ use crate::{
}; };
pub struct TrsEnv { pub struct TrsEnv {
name: String,
db: Db, db: Db,
http_client: reqwest::blocking::Client, http_client: reqwest::blocking::Client,
} }
impl Clone for TrsEnv {
fn clone(&self) -> Self {
let name = self.name.clone();
TrsEnv::new(&name).expect("Failed to clone TrsEnv")
}
}
impl TrsEnv { impl TrsEnv {
pub fn new(instance_name: &str) -> Result<Self, TrsError> { pub fn new(instance_name: &str) -> Result<Self, TrsError> {
let db = Db::create(instance_name)?; let db = Db::create(instance_name)?;
@ -17,11 +25,15 @@ impl TrsEnv {
.user_agent("cool-mist/trs") .user_agent("cool-mist/trs")
.build() .build()
.map_err(|e| TrsError::ReqwestError(e, "Failed to create HTTP client".to_string()))?; .map_err(|e| TrsError::ReqwestError(e, "Failed to create HTTP client".to_string()))?;
Ok(TrsEnv { db, http_client }) Ok(TrsEnv {
name: instance_name.to_string(),
db,
http_client,
})
} }
} }
pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, TrsError> { pub fn add_channel(ctx: &TrsEnv, args: &AddChannelArgs) -> Result<RssChannelD, TrsError> {
let rss = ctx.http_client.get(&args.link).send().map_err(|e| { let rss = ctx.http_client.get(&args.link).send().map_err(|e| {
TrsError::ReqwestError( TrsError::ReqwestError(
e, e,
@ -41,18 +53,15 @@ pub fn add_channel(ctx: &mut TrsEnv, args: &AddChannelArgs) -> Result<RssChannel
ctx.db.add_channel(&channel) ctx.db.add_channel(&channel)
} }
pub fn list_channels( pub fn list_channels(ctx: &TrsEnv, args: &ListChannelArgs) -> Result<Vec<RssChannelD>, TrsError> {
ctx: &mut TrsEnv,
args: &ListChannelArgs,
) -> Result<Vec<RssChannelD>, TrsError> {
ctx.db.list_channels(args.limit.unwrap_or(u32::MAX)) ctx.db.list_channels(args.limit.unwrap_or(u32::MAX))
} }
pub fn remove_channel(ctx: &mut TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> { pub fn remove_channel(ctx: &TrsEnv, args: &RemoveChannelArgs) -> Result<(), TrsError> {
ctx.db.remove_channel(args.id).map(|_| ()) ctx.db.remove_channel(args.id).map(|_| ())
} }
pub fn mark_read(ctx: &mut TrsEnv, args: &args::MarkReadArgs) -> Result<(), TrsError> { pub fn mark_read(ctx: &TrsEnv, args: &args::MarkReadArgs) -> Result<(), TrsError> {
match args.unread { match args.unread {
true => ctx.db.mark_article_unread(args.id as i64)?, true => ctx.db.mark_article_unread(args.id as i64)?,
false => ctx.db.mark_article_read(args.id as i64)?, false => ctx.db.mark_article_read(args.id as i64)?,

View File

@ -10,7 +10,7 @@ pub mod ui;
fn main() -> Result<()> { fn main() -> Result<()> {
let args = argh::from_env::<TrsArgs>(); let args = argh::from_env::<TrsArgs>();
let mut ctx = TrsEnv::new("test")?; let mut ctx = TrsEnv::new("test3")?;
match args.sub_command { match args.sub_command {
TrsSubCommand::AddChannel(args) => { TrsSubCommand::AddChannel(args) => {
commands::add_channel(&mut ctx, &args)?; commands::add_channel(&mut ctx, &args)?;
@ -50,6 +50,6 @@ fn main() -> Result<()> {
Ok(()) Ok(())
} }
TrsSubCommand::MarkRead(args) => commands::mark_read(&mut ctx, &args), TrsSubCommand::MarkRead(args) => commands::mark_read(&mut ctx, &args),
TrsSubCommand::Ui(args) => ui::ui(&mut ctx, &args), TrsSubCommand::Ui(args) => ui::ui(ctx, &args),
} }
} }

View File

@ -13,7 +13,7 @@ const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \
name TEXT NOT NULL, \ name TEXT NOT NULL, \
link TEXT NOT NULL UNIQUE, \ link TEXT NOT NULL UNIQUE, \
description TEXT, \ description TEXT, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ last_update INTEGER\
)"; )";
const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
@ -22,15 +22,15 @@ const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \
title TEXT NOT NULL, \ title TEXT NOT NULL, \
description TEXT, \ description TEXT, \
link TEXT NOT NULL UNIQUE, \ link TEXT NOT NULL UNIQUE, \
pub_date TIMESTAMP, \ pub_date INTEGER, \
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ last_update INTEGER , \
unread BOOLEAN DEFAULT FALSE, \ unread BOOLEAN DEFAULT TRUE, \
FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \ FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \
)"; )";
const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \ const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \
VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)\ VALUES (?1, ?2, ?3, ?4)\
ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=CURRENT_TIMESTAMP"; ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=?4";
const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1"; const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1";
const LIST_CHANNELS: &'static str = const LIST_CHANNELS: &'static str =
"SELECT id, name, link, description, last_update FROM Channels LIMIT ?1"; "SELECT id, name, link, description, last_update FROM Channels LIMIT ?1";
@ -38,9 +38,9 @@ const GET_CHANNEL: &'static str =
"SELECT id, name, link, description, last_update FROM Channels WHERE link = ?1"; "SELECT id, name, link, description, last_update FROM Channels WHERE link = ?1";
const ADD_ARTICLE: &'static str = const ADD_ARTICLE: &'static str =
"INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update) \ "INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \
VALUES (?1, ?2, ?3, ?4, ?5, CURRENT_TIMESTAMP) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, true) \
ON CONFLICT(link) DO UPDATE SET pub_date=?5, last_update=CURRENT_TIMESTAMP"; ON CONFLICT(link) DO UPDATE SET last_update=?6";
const GET_ARTICLES_BY_CHANNEL: &'static str = const GET_ARTICLES_BY_CHANNEL: &'static str =
"SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE channel_id = ?1"; "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE channel_id = ?1";
@ -130,7 +130,12 @@ impl Db {
self.connection self.connection
.execute( .execute(
ADD_CHANNEL, ADD_CHANNEL,
(&channel.title, &channel.link, &channel.description), (
&channel.title,
&channel.link,
&channel.description,
OffsetDateTime::now_utc().unix_timestamp(),
),
) )
.map_err(|e| TrsError::SqlError(e, "Failed to add channel".to_string()))?; .map_err(|e| TrsError::SqlError(e, "Failed to add channel".to_string()))?;
@ -201,7 +206,8 @@ impl Db {
&article.title, &article.title,
&article.description, &article.description,
&article.link, &article.link,
article.date.map(|d| d.to_string()), article.date.map(|d| d.unix_timestamp()),
OffsetDateTime::now_utc().unix_timestamp(),
), ),
) )
.map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?; .map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?;
@ -250,7 +256,7 @@ impl Db {
row.get(1)?, row.get(1)?,
row.get(2)?, row.get(2)?,
row.get(3)?, row.get(3)?,
row.get(4)?, Db::read_datetime(4, &row)?,
Vec::new(), Vec::new(),
)) ))
} }
@ -262,11 +268,26 @@ impl Db {
title: row.get(2)?, title: row.get(2)?,
description: row.get(3)?, description: row.get(3)?,
link: row.get(4)?, link: row.get(4)?,
pub_date: row.get(5).ok(), pub_date: Db::read_datetime(5, row).ok(),
last_update: row.get(6).ok(), last_update: Db::read_datetime(6, row).ok(),
unread: row.get(7)?, unread: row.get(7)?,
}) })
} }
fn read_datetime(
idx: usize,
row: &rusqlite::Row,
) -> std::result::Result<OffsetDateTime, rusqlite::Error> {
row.get::<usize, i64>(idx).map(|ts| {
OffsetDateTime::from_unix_timestamp(ts).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
idx,
rusqlite::types::Type::Integer,
Box::new(e),
)
})
})?
}
} }
impl RssChannelD { impl RssChannelD {

View File

@ -3,12 +3,17 @@ pub mod articles;
pub mod channels; pub mod channels;
pub mod controls; pub mod controls;
pub mod debug; pub mod debug;
pub mod executor;
pub mod title; pub mod title;
use std::io::Stdout; use std::{
io::Stdout,
sync::mpsc::{channel, Receiver, Sender},
thread,
};
use crate::{ use crate::{
args::{ListChannelArgs, UiArgs}, args::{self, ListChannelArgs, UiArgs},
commands::{self, TrsEnv}, commands::{self, TrsEnv},
error::{Result, TrsError}, error::{Result, TrsError},
persistence::RssChannelD, persistence::RssChannelD,
@ -18,6 +23,7 @@ use channels::ChannelsWidget;
use controls::ControlsWidget; use controls::ControlsWidget;
use crossterm::event; use crossterm::event;
use debug::DebugWidget; use debug::DebugWidget;
use executor::UiCommandExecutor;
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{Block, Borders}, widgets::{Block, Borders},
@ -33,6 +39,10 @@ pub struct AppState {
highlighted_channel: Option<usize>, highlighted_channel: Option<usize>,
highlighted_article: Option<usize>, highlighted_article: Option<usize>,
last_action: Option<UiAction>, last_action: Option<UiAction>,
show_add_channel_ui: bool,
add_channel: String,
dispatcher: Sender<UiCommandDispatchActions>,
receiver: Receiver<u64>,
} }
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
@ -52,10 +62,31 @@ pub enum UiAction {
FocusEntryDown, FocusEntryDown,
ToggleDebug, ToggleDebug,
OpenArticle, OpenArticle,
ShowAddChannelUi,
RemoveChannel,
ToggleReadStatus,
Exit, Exit,
} }
pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> { #[derive(Debug, Clone, PartialEq)]
pub enum PopupUiAction {
None,
Submit,
AddChar(char),
Backspace,
Close,
}
#[derive(Debug)]
pub enum UiCommandDispatchActions {
AddChannel(args::AddChannelArgs),
RemoveChannel(args::RemoveChannelArgs),
MarkArticleRead(args::MarkReadArgs),
}
pub fn ui(ctx: TrsEnv, args: &UiArgs) -> Result<()> {
let (tdispatch, rdispatch) = channel();
let (tupdate, rupdate) = channel();
let mut terminal = ratatui::init(); let mut terminal = ratatui::init();
let mut app_state = AppState { let mut app_state = AppState {
channels: Vec::new(), channels: Vec::new(),
@ -66,19 +97,31 @@ pub fn ui(ctx: &mut TrsEnv, args: &UiArgs) -> Result<()> {
highlighted_article: None, highlighted_article: None,
highlighted_channel: None, highlighted_channel: None,
last_action: None, last_action: None,
show_add_channel_ui: false,
add_channel: String::new(),
dispatcher: tdispatch,
receiver: rupdate,
}; };
let channels = commands::list_channels(ctx, &ListChannelArgs { limit: None })?; let ctx_cloned = ctx.clone();
let executor = UiCommandExecutor::new(rdispatch, tupdate);
let executor_handle = thread::spawn(move || {
executor.run(ctx_cloned);
});
let channels = commands::list_channels(&ctx, &ListChannelArgs { limit: None })?;
app_state.channels = channels; app_state.channels = channels;
loop { loop {
draw(&app_state, &mut terminal)?; draw(&app_state, &mut terminal)?;
handle_events(&mut app_state)?; handle_events(&mut app_state, &ctx)?;
if app_state.exit { if app_state.exit {
break; break;
} }
} }
drop(app_state);
executor_handle.join().unwrap();
ratatui::restore(); ratatui::restore();
Ok(()) Ok(())
} }
@ -93,8 +136,20 @@ fn draw(app_state: &AppState, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
Ok(()) Ok(())
} }
fn handle_events(state: &mut AppState) -> Result<()> { fn handle_events(state: &mut AppState, ctx: &TrsEnv) -> Result<()> {
let recv_action = state.receiver.try_recv();
if let Ok(_) = recv_action {
let channels = commands::list_channels(&ctx, &ListChannelArgs { limit: None })?;
state.channels = channels;
}
let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?; let raw_event = event::read().map_err(|e| TrsError::TuiError(e))?;
if state.show_add_channel_ui {
let event = controls::parse_popup_ui_action(raw_event);
return actions::handle_popup_action(state, event);
}
let event = controls::parse_ui_action(raw_event); let event = controls::parse_ui_action(raw_event);
state.last_action = Some(event.clone()); state.last_action = Some(event.clone());
actions::handle_action(state, event) actions::handle_action(state, event)

View File

@ -1,6 +1,10 @@
use crate::{error::TrsError, persistence::RssChannelD}; use crate::{
args,
error::TrsError,
persistence::{RssArticleD, RssChannelD},
};
use super::{AppState, FocussedPane, UiAction}; use super::{AppState, FocussedPane, PopupUiAction, UiAction, UiCommandDispatchActions};
pub fn handle_action( pub fn handle_action(
app_state: &mut AppState, app_state: &mut AppState,
@ -19,21 +23,100 @@ pub fn handle_action(
UiAction::OpenArticle => { UiAction::OpenArticle => {
if let Some(channel_idx) = app_state.highlighted_channel { if let Some(channel_idx) = app_state.highlighted_channel {
if let Some(article_idx) = app_state.highlighted_article { if let Some(article_idx) = app_state.highlighted_article {
if let Some(channel) = app_state.channels.get(channel_idx) { if let Some(channel) = app_state.channels.get_mut(channel_idx) {
if let Some(article) = channel.articles.get(article_idx) { if let Some(article) = channel.articles.get_mut(article_idx) {
let open_res = open::that(&article.link); article.unread = false;
if let Err(e) = open_res { app_state
eprintln!("Failed to open article: {}", e); .dispatcher
} .send(UiCommandDispatchActions::MarkArticleRead(
args::MarkReadArgs {
id: article.id as u32,
unread: false,
},
))
.unwrap();
_ = open::that(&article.link);
} }
} }
} }
} }
} }
UiAction::ShowAddChannelUi => {
app_state.show_add_channel_ui = true;
}
UiAction::RemoveChannel => {
let hi_channel = get_highlighted_channel(app_state);
let Some(channel) = hi_channel else {
return Ok(());
};
let remove_channel_args = args::RemoveChannelArgs {
id: channel.id as u32,
};
app_state
.dispatcher
.send(UiCommandDispatchActions::RemoveChannel(remove_channel_args))
.unwrap();
}
UiAction::ToggleReadStatus => {
let article = get_highlighted_article_mut(app_state);
let mut article_id = None;
let mut unread = None;
if let Some(article) = article {
article.unread = !article.unread;
article_id = Some(article.id);
unread = Some(article.unread);
}
if let Some(article_id) = article_id {
if let Some(unread) = unread {
app_state
.dispatcher
.send(UiCommandDispatchActions::MarkArticleRead(
args::MarkReadArgs {
id: article_id as u32,
unread,
},
))
.unwrap();
}
}
}
}; };
Ok(()) Ok(())
} }
pub fn handle_popup_action(
state: &mut AppState,
event: PopupUiAction,
) -> std::result::Result<(), TrsError> {
match event {
PopupUiAction::None => {}
PopupUiAction::Submit => {
let add_channel_args = args::AddChannelArgs {
link: state.add_channel.clone(),
};
state
.dispatcher
.send(UiCommandDispatchActions::AddChannel(add_channel_args))
.unwrap();
state.show_add_channel_ui = false;
}
PopupUiAction::AddChar(c) => {
state.add_channel.push(c);
}
PopupUiAction::Backspace => {
state.add_channel.pop();
}
PopupUiAction::Close => {
state.show_add_channel_ui = false;
}
};
Ok(())
}
fn saturating_add(num: usize, to_add: usize, max: usize) -> usize { fn saturating_add(num: usize, to_add: usize, max: usize) -> usize {
if num + to_add > max { if num + to_add > max {
max max
@ -60,6 +143,18 @@ fn get_highlighted_channel<'a>(app_state: &'a AppState) -> Option<&'a RssChannel
.and_then(|idx| app_state.channels.get(idx).or_else(|| None)) .and_then(|idx| app_state.channels.get(idx).or_else(|| None))
} }
fn get_highlighted_channel_mut<'a>(app_state: &'a mut AppState) -> Option<&'a mut RssChannelD> {
app_state
.highlighted_channel
.and_then(|idx| app_state.channels.get_mut(idx).or_else(|| None))
}
fn get_highlighted_article_mut<'a>(app_state: &'a mut AppState) -> Option<&'a mut RssArticleD> {
let hi_article = app_state.highlighted_article?;
let channel = get_highlighted_channel_mut(app_state)?;
channel.articles.get_mut(hi_article)
}
fn focus_entry_up(app_state: &mut AppState) { fn focus_entry_up(app_state: &mut AppState) {
match app_state.focussed { match app_state.focussed {
FocussedPane::Channels => decrement_highlighted_channel_idx(app_state), FocussedPane::Channels => decrement_highlighted_channel_idx(app_state),
@ -77,12 +172,13 @@ fn focus_entry_down(app_state: &mut AppState) {
} }
fn increment_highlighted_channel_idx(app_state: &mut AppState) -> Option<bool> { fn increment_highlighted_channel_idx(app_state: &mut AppState) -> Option<bool> {
let max_channel_idx = app_state.channels.len().saturating_sub(1); let channels_len = app_state.channels.len();
if max_channel_idx == 0 { if channels_len == 0 {
app_state.highlighted_channel = None; app_state.highlighted_channel = None;
return Some(false); return Some(false);
} }
let max_channel_idx = channels_len.saturating_sub(1);
app_state.highlighted_channel = app_state app_state.highlighted_channel = app_state
.highlighted_channel .highlighted_channel
.map(|idx| saturating_add(idx, 1, max_channel_idx)) .map(|idx| saturating_add(idx, 1, max_channel_idx))

View File

@ -58,22 +58,20 @@ impl<'a> Widget for ArticlesWidget<'a> {
.filter(|h| *h == idx) .filter(|h| *h == idx)
.is_some(); .is_some();
let mut lines = Vec::new(); let mut lines = Vec::new();
let id = Span::styled( let id = if article.unread {
format!("{:>3}. ", idx + 1), Span::styled(" * ", Style::default().fg(Color::Red))
get_article_id_style(current_highlighted), } else {
); Span::styled(
format!("{:>3}. ", idx + 1),
get_article_id_style(current_highlighted),
)
};
let title = Span::styled( let title = Span::styled(
article.title.clone(), article.title.clone(),
get_article_title_style(current_highlighted), get_article_title_style(current_highlighted),
); );
let unread = if article.unread { lines.push(Line::from(vec![id, title]));
Span::styled(" (unread)", Style::default().fg(Color::Red))
} else {
Span::raw("")
};
lines.push(Line::from(vec![id, title, unread]));
let para = Paragraph::new(lines) let para = Paragraph::new(lines)
.block(Block::default()) .block(Block::default())
.style(get_channel_list_item_block_style(current_highlighted)) .style(get_channel_list_item_block_style(current_highlighted))

View File

@ -5,6 +5,7 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Paragraph, Widget}, widgets::{Block, Paragraph, Widget},
}; };
use time::format_description;
use super::AppState; use super::AppState;
@ -12,6 +13,10 @@ pub struct ChannelsWidget<'a> {
state: &'a AppState, state: &'a AppState,
} }
pub struct AddChannelWidget<'a> {
state: &'a str,
}
impl<'a> ChannelsWidget<'a> { impl<'a> ChannelsWidget<'a> {
pub fn new(state: &'a AppState) -> Self { pub fn new(state: &'a AppState) -> Self {
Self { state } Self { state }
@ -44,36 +49,50 @@ impl<'a> Widget for ChannelsWidget<'a> {
.highlighted_channel .highlighted_channel
.filter(|h| *h == idx) .filter(|h| *h == idx)
.is_some(); .is_some();
let mut lines = Vec::new(); let mut spans = Vec::new();
let id = Span::styled( let id = Span::styled(
format!("{:>3}. ", idx + 1), format!("{:>3}. ", idx + 1),
get_channel_id_style(current_highlighted), get_channel_id_style(current_highlighted),
); );
spans.push(id);
let title = Span::styled( let title = Span::styled(
channel.title.clone(), channel.title.clone(),
get_channel_title_style(current_highlighted), get_channel_title_style(current_highlighted),
); );
spans.push(title);
lines.push(Line::from(vec![id, title])); let format = format_description::parse("[year]-[month]-[day]").unwrap();
if let Some(article) = channel.articles.first() { if let Some(article) = channel.articles.first() {
let pub_date_text = match article.pub_date { let pub_date_text = match article.pub_date {
Some(date) => format!("Last update: {}", date), Some(date) => format!(" {}", date.format(&format).unwrap()),
None => "".to_string(), None => "".to_string(),
}; };
let pub_date = Span::styled( let pub_date = Span::styled(
pub_date_text, pub_date_text,
get_channel_pub_date_style(current_highlighted), get_channel_pub_date_style(current_highlighted),
); );
lines.push(Line::from(vec![pub_date])); spans.push(pub_date);
} }
let para = Paragraph::new(lines) let para = Paragraph::new(Line::from(spans))
.block(Block::default()) .block(Block::default())
.style(get_channel_list_item_block_style(current_highlighted)) .style(get_channel_list_item_block_style(current_highlighted))
.alignment(Alignment::Left); .alignment(Alignment::Left);
para.render(row, buf); para.render(row, buf);
} }
if self.state.show_add_channel_ui {
let add_channel_area = Layout::default()
.direction(Direction::Vertical)
.constraints(Constraint::from_percentages(vec![90, 10]))
.split(area)
.to_vec();
AddChannelWidget {
state: &self.state.add_channel,
}
.render(add_channel_area[1], buf);
}
} }
} }
@ -116,3 +135,21 @@ fn get_channel_title_style(highlighted: bool) -> Style {
Style::default() Style::default()
} }
} }
impl<'a> Widget for AddChannelWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.title_top(Line::from("Add Channel").centered())
.title_style(
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED),
)
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let para = Paragraph::new(Line::from(self.state)).block(block);
para.render(area, buf);
}
}

View File

@ -7,10 +7,33 @@ use ratatui::{
widgets::{Block, Borders, Paragraph, Widget}, widgets::{Block, Borders, Paragraph, Widget},
}; };
use super::UiAction; use super::{PopupUiAction, UiAction};
pub struct ControlsWidget; pub struct ControlsWidget;
pub fn parse_popup_ui_action(raw_event: Event) -> PopupUiAction {
match raw_event {
Event::Key(key_event) => {
if key_event.kind != KeyEventKind::Press {
return PopupUiAction::None;
}
if key_event.modifiers != KeyModifiers::NONE {
return PopupUiAction::None;
}
return match key_event.code {
KeyCode::Backspace => PopupUiAction::Backspace,
KeyCode::Char(c) => PopupUiAction::AddChar(c),
KeyCode::Enter => PopupUiAction::Submit,
KeyCode::Esc => PopupUiAction::Close,
_ => PopupUiAction::None,
};
}
_ => PopupUiAction::None,
}
}
pub fn parse_ui_action(raw_event: Event) -> UiAction { pub fn parse_ui_action(raw_event: Event) -> UiAction {
match raw_event { match raw_event {
Event::Key(key_event) => { Event::Key(key_event) => {
@ -36,6 +59,9 @@ pub fn parse_ui_action(raw_event: Event) -> UiAction {
KeyCode::Char('j') => UiAction::FocusEntryDown, KeyCode::Char('j') => UiAction::FocusEntryDown,
KeyCode::Char('d') => UiAction::ToggleDebug, KeyCode::Char('d') => UiAction::ToggleDebug,
KeyCode::Char('q') => UiAction::Exit, KeyCode::Char('q') => UiAction::Exit,
KeyCode::Char('a') => UiAction::ShowAddChannelUi,
KeyCode::Char('r') => UiAction::RemoveChannel,
KeyCode::Char('u') => UiAction::ToggleReadStatus,
KeyCode::Enter => UiAction::OpenArticle, KeyCode::Enter => UiAction::OpenArticle,
_ => UiAction::None, _ => UiAction::None,
}; };
@ -69,6 +95,12 @@ impl Widget for ControlsWidget {
description!(" to navigate up/down, "), description!(" to navigate up/down, "),
control!("h/l"), control!("h/l"),
description!(" to switch between channels and articles, "), description!(" to switch between channels and articles, "),
control!("a"),
description!(" add a new RSS channel, "),
control!("r"),
description!(" remove an RSS channel, "),
control!("u"),
description!(" toggle read state of article, "),
control!("q"), control!("q"),
description!(" to exit"), description!(" to exit"),
]) ])

View File

@ -23,27 +23,31 @@ impl<'a> Widget for DebugWidget<'a> {
lines.push(format!("channels: {}", self.state.channels.len())); lines.push(format!("channels: {}", self.state.channels.len()));
lines.push(format!("highlighted: {:?}", self.state.highlighted_channel)); lines.push(format!("highlighted: {:?}", self.state.highlighted_channel));
if let Some(h) = self.state.highlighted_channel { if let Some(h) = self.state.highlighted_channel {
for channel in &self.state.channels { let hi_channel = self.state.channels.get(h);
if channel.id as usize == h { if let Some(channel) = hi_channel {
lines.push(format!( lines.push(format!(
"highlighted channel: ({},{},{},{:?})", "highlighted channel: ({}, {:?}, {})",
channel.id, channel.title, channel.link, channel.last_update channel.id, channel.last_update, channel.title,
)); ));
if let Some(article) = channel.articles.first() { if let Some(article) = channel.articles.first() {
lines.push(format!(
"first article: ({} ,{:?}, {})",
article.id, article.last_update, article.title,
));
}
if let Some(h) = self.state.highlighted_article {
let hi_article = channel.articles.get(h);
if let Some(article) = hi_article {
lines.push(format!( lines.push(format!(
"first article: ({},{},{},{:?})", "highlighted article: ({}, {:?}, {}, unread={})",
article.id, article.title, article.link, article.last_update article.id, article.last_update, article.title, article.unread,
)); ));
} }
} }
} }
} }
if let Some(h) = self.state.highlighted_article {
lines.push(format!("highlighted article: {}", h));
}
let line_areas = Layout::default() let line_areas = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(

49
src/ui/executor.rs Normal file
View File

@ -0,0 +1,49 @@
use std::sync::mpsc::{Receiver, Sender};
use super::UiCommandDispatchActions;
pub struct UiCommandExecutor {
pub command_receiver: Receiver<UiCommandDispatchActions>,
pub status_sender: Sender<u64>,
}
impl UiCommandExecutor {
pub fn new(
command_receiver: Receiver<UiCommandDispatchActions>,
status_sender: Sender<u64>,
) -> Self {
UiCommandExecutor {
command_receiver,
status_sender,
}
}
pub fn run(&self, ctx: crate::commands::TrsEnv) -> () {
loop {
let action = self.command_receiver.recv();
let Ok(action) = action else {
break;
};
match action {
UiCommandDispatchActions::AddChannel(args) => {
if let Ok(_) = crate::commands::add_channel(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
};
}
UiCommandDispatchActions::RemoveChannel(args) => {
if let Ok(_) = crate::commands::remove_channel(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
}
}
UiCommandDispatchActions::MarkArticleRead(args) => {
if let Ok(_) = crate::commands::mark_read(&ctx, &args) {
self.status_sender.send(1).unwrap_or_default();
}
}
}
}
}
}