mod components;
mod lib;
mod view;
use std::time::{Duration, Instant};
use lib::{FeedClient, FeedState, FlatFeedState, History, Kiosk};
use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalBridge};
use tuirealm::{
Application, AttrValue, Attribute, NoUserEvent, PollStrategy, State, StateValue, Update,
};
use crate::config::Config;
use crate::feed::{Feed, FeedSource};
use crate::helpers::open as open_helpers;
const FORCED_REDRAW_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
pub enum Id {
ArticleAuthors,
ArticleDate,
ArticleLink,
ArticleList,
ArticleSummary,
ArticleTitle,
ErrorPopup,
FeedList,
GlobalListener,
QuitPopup,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Msg {
ArticleBlur,
ArticleChanged(usize),
ArticleListBlur,
CloseErrorPopup,
CloseQuitPopup,
FeedChanged(usize),
FeedListBlur,
FetchAllSources,
FetchSource,
MarkSourceAsRead,
MarkAllSourcesAsRead,
GoReadArticle,
OpenArticle,
Quit,
ShowQuitPopup,
None,
}
pub struct Ui {
application: Application<Id, Msg, NoUserEvent>,
client: FeedClient,
config: Config,
history: History,
kiosk: Kiosk,
last_redraw: Instant,
redraw: bool,
terminal: TerminalBridge<CrosstermTerminalAdapter>,
}
impl Ui {
pub fn init(config: Config, ticks: u64) -> Result<Self, Box<dyn std::error::Error>> {
let mut terminal = TerminalBridge::init_crossterm()?;
let _ = terminal.disable_mouse_capture();
let history_path = History::default_path()?;
let history = History::load(&history_path)?;
let mut kiosk = Kiosk::default();
for name in config.sources.keys() {
kiosk.insert_feed(name, FeedState::Loading);
}
Ok(Self {
application: Self::init_application(&kiosk, Duration::from_millis(ticks)),
client: FeedClient::default(),
config,
history,
kiosk,
last_redraw: Instant::now(),
redraw: true,
terminal,
})
}
pub fn run(mut self) -> Result<(), Box<dyn std::error::Error>> {
self.fetch_all_sources();
let mut quit = false;
while !quit {
match self.application.tick(PollStrategy::UpTo(3)) {
Ok(messages) if messages.is_empty() => {}
Ok(messages) => {
self.redraw = true;
for msg in messages.into_iter() {
if let Some(Msg::Quit) = self.update(Some(msg)) {
quit = true;
break;
}
}
}
Err(err) => {
self.mount_error_popup(format!("Application error: {}", err));
}
}
self.poll_fetched_sources();
self.check_force_redraw();
if self.redraw {
self.view();
}
}
self.history.save()?;
Ok(())
}
fn fetch_all_sources(&mut self) {
let sources: Vec<_> = self
.config
.sources
.iter()
.map(|(name, uri)| (name.clone(), uri.clone()))
.collect();
for (name, source) in sources.into_iter() {
self.fetch_source(name.as_str(), source);
}
}
fn fetch_source(&mut self, name: &str, source: FeedSource) {
self.client.fetch(name, &source);
self.update_source(name, FeedState::Loading);
self.update_feed_list_item(
name,
FlatFeedState::Loading,
self.history.is_source_read(name),
);
self.redraw = true;
}
fn update_source(&mut self, name: &str, state: FeedState) {
self.kiosk.insert_feed(name, state);
}
fn poll_fetched_sources(&mut self) {
if let Some((name, result)) = self.client.poll() {
let state = match result {
Ok(feed) => FeedState::Success(feed),
Err(err) => {
self.mount_error_popup(format!(r#"Could not fetch feed "{}": {}"#, name, err));
FeedState::Error(err)
}
};
if let FeedState::Success(feed) = &state {
let articles: Vec<_> = feed.articles().collect();
self.history.filter_articles(name.as_str(), &articles);
for article in articles {
self.history.insert(&feed.name, article);
}
}
let flat_state = FlatFeedState::from(&state);
self.update_source(name.as_str(), state);
self.update_feed_list_item(
name.as_str(),
flat_state,
self.history.is_source_read(&name),
);
let selected_feed = self.get_selected_feed();
if self.is_article_list_empty() && selected_feed.is_some() {
let article_list = self.get_article_list(
&self.config,
selected_feed.expect("selected feed cannot be none"),
&self.history,
self.max_article_name_len(),
None,
);
self.init_article(article_list);
}
self.redraw = true;
}
}
fn check_force_redraw(&mut self) {
if self.client.running() && self.since_last_redraw() >= FORCED_REDRAW_INTERVAL {
self.redraw = true;
}
}
fn since_last_redraw(&self) -> Duration {
self.last_redraw.elapsed()
}
fn sorted_sources(&self) -> Vec<&String> {
let mut sources = self.kiosk.sources();
sources.sort();
sources
}
fn get_selected_feed(&self) -> Option<&Feed> {
let feed = self.get_selected_feed_name()?;
self.kiosk.get_feed(feed.as_str())
}
fn get_selected_feed_name(&self) -> Option<String> {
let State::One(StateValue::Usize(feed)) = self.application.state(&Id::FeedList).ok()?
else {
return None;
};
self.sorted_sources().get(feed).cloned().cloned()
}
fn mark_viewed_article(&mut self, index: usize) {
let Some(feed_name) = self.get_selected_feed_name() else {
return;
};
let Some(feed) = self.get_selected_feed().cloned() else {
return;
};
let Some(article) = feed.articles().nth(index).cloned() else {
return;
};
let was_read = self.history.is_article_read(feed_name.as_str(), &article);
if !was_read {
self.history.read(feed_name.as_str(), &article);
self.reload_article_list(&feed, Some(index));
}
if self.history.is_source_read(&feed_name) {
let state = self
.kiosk
.get_feed_state(&feed_name)
.map(FlatFeedState::from)
.unwrap_or(FlatFeedState::Success);
self.update_feed_list_item(&feed_name, state, true);
}
}
fn mark_source_as_read(&mut self, name: &str) {
self.history.read_source(name);
let selected_line = self.application.state(&Id::FeedList).ok();
let selected_line = match selected_line {
Some(State::One(StateValue::Usize(line))) => Some(line),
_ => None,
};
let Some(feed) = self.get_selected_feed().cloned() else {
return;
};
self.reload_article_list(&feed, selected_line);
let state = self
.kiosk
.get_feed_state(name)
.map(FlatFeedState::from)
.unwrap_or(FlatFeedState::Success);
self.update_feed_list_item(&feed.name, state, true);
}
fn mark_all_sources_as_read(&mut self) {
self.history.read_all();
let selected_line = self.application.state(&Id::FeedList).ok();
let selected_line = match selected_line {
Some(State::One(StateValue::Usize(line))) => Some(line),
_ => None,
};
let Some(feed) = self.get_selected_feed().cloned() else {
return;
};
self.reload_article_list(&feed, selected_line);
let sources_with_states = self.kiosk.get_state();
for (feed_name, state) in sources_with_states {
self.update_feed_list_item(&feed_name, state, true);
}
}
fn reload_article_list(&mut self, feed: &Feed, selected_line: Option<usize>) {
let articles = self.get_article_list(
&self.config,
feed,
&self.history,
self.max_article_name_len(),
selected_line,
);
assert!(
self.application
.remount(Id::ArticleList, Box::new(articles), vec![])
.is_ok()
);
}
}
impl Update<Msg> for Ui {
fn update(&mut self, msg: Option<Msg>) -> Option<Msg> {
match msg.unwrap_or(Msg::None) {
Msg::ArticleBlur => {
assert!(self.application.active(&Id::ArticleList).is_ok());
None
}
Msg::ArticleChanged(article) => {
self.update_article(article);
self.mark_viewed_article(article);
None
}
Msg::ArticleListBlur => {
assert!(self.application.active(&Id::FeedList).is_ok());
None
}
Msg::CloseErrorPopup => {
self.umount_error_popup();
None
}
Msg::CloseQuitPopup => {
self.umount_quit_popup();
None
}
Msg::FeedChanged(feed) => {
let feed = self.sorted_sources().get(feed).cloned()?;
let feed = self.kiosk.get_feed(feed.as_str()).cloned()?;
self.mark_viewed_article(0);
self.reload_article_list(&feed, None);
self.update_article(0);
None
}
Msg::FeedListBlur => {
assert!(self.application.active(&Id::ArticleList).is_ok());
None
}
Msg::FetchSource => {
if let Some(name) = self.get_selected_feed_name() {
let uri = self.config.sources.get(&name).cloned();
if let Some(uri) = uri {
self.fetch_source(name.as_str(), uri)
}
}
None
}
Msg::FetchAllSources => {
self.fetch_all_sources();
None
}
Msg::GoReadArticle => {
let _ = self.application.active(&Id::ArticleSummary);
None
}
Msg::MarkAllSourcesAsRead => {
self.mark_all_sources_as_read();
None
}
Msg::MarkSourceAsRead => {
if let Some(name) = self.get_selected_feed_name() {
self.mark_source_as_read(name.as_str());
}
None
}
Msg::OpenArticle => {
if let Ok(Some(AttrValue::String(url))) =
self.application.query(&Id::ArticleLink, Attribute::Text)
{
if let Err(err) = open_helpers::open_link(url.as_str()) {
self.mount_error_popup(err);
}
}
None
}
Msg::Quit => Some(Msg::Quit),
Msg::ShowQuitPopup => {
self.mount_quit_popup();
None
}
Msg::None => None,
}
}
}
impl Drop for Ui {
fn drop(&mut self) {
let _ = self.terminal.restore();
}
}