add/remove channel, toggle read status
This commit is contained in:
parent
d43996d049
commit
2a82e3bc66
@ -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)?,
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
67
src/ui.rs
67
src/ui.rs
@ -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)
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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"),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -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
49
src/ui/executor.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user