use crate::prelude::*;
use crate::ui::articles_list::model::ArticleListModelData;
use std::sync::Arc;
use getset::{Getters, MutGetters};
use news_flash::models::{ArticleFilter, Marked, Read};
use ratatui::layout::Constraint;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Table,
TableState, Widget,
};
use strum::IntoEnumIterator;
#[derive(Getters, MutGetters)]
#[getset(get = "pub(super)")]
pub struct FilterState {
default_sort_order: SortOrder,
augmented_article_filter: Option<AugmentedArticleFilter>,
#[get_mut = "pub(super)"]
article_scope: ArticleScope,
#[get_mut = "pub(super)"]
article_search_query: Option<ArticleQuery>,
#[get_mut = "pub(super)"]
article_adhoc_filter: Option<ArticleQuery>,
#[get_mut = "pub(super)"]
adhoc_sort_order: Option<SortOrder>,
#[get_mut = "pub(super)"]
reverse_sort_order: bool,
#[get_mut = "pub(super)"]
apply_article_adhoc_filter: bool,
#[get_mut = "pub(super)"]
sticky_adhoc_filter: bool,
}
impl FilterState {
pub fn new(article_scope: ArticleScope, default_sort_order: SortOrder) -> Self {
Self {
default_sort_order,
article_scope,
augmented_article_filter: None,
article_search_query: None,
article_adhoc_filter: None,
adhoc_sort_order: None,
apply_article_adhoc_filter: false,
reverse_sort_order: false,
sticky_adhoc_filter: false,
}
}
pub(super) fn generate_effective_filter(&self) -> Option<ArticleFilter> {
let augmented_article_filter = self.augmented_article_filter.as_ref()?;
let mut article_filter = augmented_article_filter.article_filter.clone();
if !augmented_article_filter.defines_scope() {
match self.article_scope {
ArticleScope::All => {}
ArticleScope::Unread => {
article_filter.unread = Some(Read::Unread);
article_filter.marked = None;
}
ArticleScope::Marked => {
article_filter.marked = Some(Marked::Marked);
article_filter.unread = None;
}
}
}
Some(article_filter)
}
pub fn get_effective_scope(&self) -> Option<ArticleScope> {
if let Some(augmented_article_filter) = self.augmented_article_filter.as_ref()
&& augmented_article_filter.defines_scope()
{
return None;
}
Some(self.article_scope)
}
pub fn uses_default_sort_order(&self) -> bool {
self.adhoc_sort_order.is_none()
&& (self
.augmented_article_filter
.as_ref()
.is_none_or(|filter| filter.article_query.sort_order().is_none()))
&& !*self.reverse_sort_order()
}
pub fn get_effective_sort_order(&self) -> SortOrder {
self.adhoc_sort_order
.as_ref()
.or_else(|| {
self.article_adhoc_filter
.as_ref()
.and_then(|filter| filter.sort_order().as_ref())
})
.or_else(|| {
self.augmented_article_filter
.as_ref()
.and_then(|filter| filter.article_query.sort_order().as_ref())
})
.unwrap_or(&self.default_sort_order)
.to_owned()
.reverse(self.reverse_sort_order)
}
pub fn on_new_article_filter(&mut self, article_filter: AugmentedArticleFilter) {
self.augmented_article_filter = Some(article_filter);
self.apply_article_adhoc_filter = self.sticky_adhoc_filter;
}
pub fn on_new_article_adhoc_filter(
&mut self,
article_adhoc_filter: ArticleQuery,
sticky: bool,
) {
self.article_adhoc_filter = Some(article_adhoc_filter);
self.apply_article_adhoc_filter = true;
self.sticky_adhoc_filter = sticky;
}
pub fn clear_sort_order(&mut self) {
self.adhoc_sort_order = None;
self.reverse_sort_order = false;
}
}
impl Widget for &mut ArticlesList {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) {
let block = self
.view_data
.gen_block(&self.config, &self.filter_state, self.is_focused);
let inner = block.inner(area);
block.render(area, buf);
*self.view_data.article_lines_mut() = Some(area.height.saturating_sub(1));
StatefulWidget::render(
&self.view_data.table,
inner,
buf,
&mut self.view_data.table_state,
);
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.symbols(self.config.scrollbar_set())
.style(self.config.theme.eff_border(self.is_focused));
let scrollbar_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height.saturating_sub(1),
};
StatefulWidget::render(
scrollbar,
scrollbar_area,
buf,
&mut self.view_data.scrollbar_state,
);
}
}
#[derive(Default, Getters, MutGetters)]
#[getset(get = "pub(super)")]
pub struct ArticleListViewData<'a> {
table: Table<'a>,
#[getset(get_mut = "pub(super)")]
table_state: TableState,
#[getset(get_mut = "pub(super)")]
scrollbar_state: ScrollbarState,
#[getset(get_mut = "pub(super)", get = "pub(super)")]
article_lines: Option<u16>,
}
impl<'a> ArticleListViewData<'a> {
fn build_title(&self, filter_state: &FilterState, config: &Config) -> Line<'static> {
let mut title = Line::styled("", config.theme.header());
let spans = &mut title.spans;
if let Some(article_scope) = filter_state.get_effective_scope() {
for scope in ArticleScope::iter() {
let style = if scope == article_scope {
config.theme.header()
} else {
config.theme.inactive()
};
spans.push(" ".into());
spans.push(Span::styled(scope.to_icon(config).to_string(), style));
}
spans.push(" ".into());
}
let filter_info = match filter_state.article_adhoc_filter {
Some(_) if filter_state.apply_article_adhoc_filter => " ï‚° ",
Some(_) => "  ",
_ => "",
};
spans.push(Span::styled(filter_info, config.theme.header()));
if !config.hide_default_sort_order || !filter_state.uses_default_sort_order() {
let filter_text = &format!(
" {} {} ",
if *filter_state.reverse_sort_order() {
"ó°’¿"
} else {
"󰌼"
},
filter_state.get_effective_sort_order()
);
spans.push(Span::styled(filter_text.to_owned(), config.theme.header()));
}
title
}
pub fn update(
&mut self,
config: Arc<Config>,
model_data: &ArticleListModelData,
filter_state: &FilterState,
_is_focused: bool,
) {
let selected_style = config.theme.selected(&Default::default());
let read_icon = config.read_icon.to_string();
let unread_icon = config.unread_icon.to_string();
let marked_icon = config.marked_icon.to_string();
let unmarked_icon = config.unmarked_icon.to_string();
let placeholders: Vec<&str> = config
.article_table
.split(",")
.map(|placeholder| placeholder.trim())
.collect();
let mut max_tags: u16 = 0;
let entries: Vec<Row> = model_data
.articles()
.iter()
.map(|article| {
let row_vec: Vec<Line> = placeholders
.iter()
.map(|placeholder| match *placeholder {
"{title}" => html_sanitize(
article
.title
.as_deref()
.or(article.summary.as_deref())
.unwrap_or("no title and summary"),
)
.into(),
"{tag_icons}" => Line::from(
match model_data.tags_for_article().get(&article.article_id) {
Some(tag_ids) => {
max_tags = u16::max(max_tags, tag_ids.len() as u16);
tag_ids
.iter()
.map(|tag_id| {
let Some(tag) = model_data.tag_map().get(tag_id) else {
return Span::from("");
};
let style = match NewsFlashUtils::tag_color(tag) {
Some(color) => config.theme.tag().fg(color),
None => config.theme.tag(),
};
Span::styled(config.tag_icon.to_string(), style)
})
.collect::<Vec<Span>>()
}
None => vec![Span::from("")],
},
),
"{author}" => {
html_sanitize(article.author.as_deref().unwrap_or("no author")).into()
}
"{feed}" => html_sanitize(
model_data
.feed_map()
.get(&article.feed_id)
.map(|feed| feed.label.as_str())
.unwrap_or("unknown feed"),
)
.into(),
"{date}" => article
.date
.with_timezone(&chrono::Local)
.format(&config.date_format)
.to_string()
.into(),
"{age}" => {
let now = chrono::Utc::now();
let duration = now.signed_duration_since(article.date);
let weeks = duration.num_weeks();
let days = duration.num_days();
let hours = duration.num_hours();
let minutes = duration.num_minutes();
let seconds = duration.num_seconds();
if weeks > 0 {
format!("{:>2}w", weeks)
} else if days > 0 {
format!("{:>2}d", days)
} else if hours > 0 {
format!("{:>2}h ", hours)
} else if minutes > 0 {
format!("{:>2}m", minutes)
} else {
format!("{:>2}s", seconds)
}
}
.into(),
"{read}" => if article.unread == Read::Read {
format!(" {}", read_icon)
} else {
format!(" {}", unread_icon)
}
.into(),
"{marked}" => if article.marked == Marked::Marked {
format!(" {}", marked_icon)
} else {
format!(" {}", unmarked_icon)
}
.into(),
"{url}" => article
.url
.as_ref()
.map(|url| url.to_string())
.unwrap_or("?".into())
.into(),
"{flagged}" => if model_data.flagged_articles().is_empty() {
"".to_string()
} else if model_data.flagged_articles().contains(&article.article_id) {
format!(" {}", config.flagged_icon)
} else {
" ".to_string()
}
.into(),
_ => format!("{placeholder}?").into(),
})
.collect();
let mut style = match filter_state.article_search_query.as_ref() {
Some(query)
if query.test(
article,
&ArticleQueryContext {
feed_map: model_data.feed_map(),
category_for_feed: model_data.category_for_feed(),
tags_for_article: model_data.tags_for_article(),
tag_map: model_data.tag_map(),
last_sync: model_data.last_sync(),
flagged: model_data.flagged_articles(),
},
) =>
{
config.theme.highlighted(&config.theme.article())
}
_ => config.theme.article(),
};
style = if article.unread == Read::Read {
config.theme.read(&style)
} else {
config.theme.unread(&style)
};
if model_data.flagged_articles().contains(&article.article_id) {
style = config.theme.flagged(&style);
}
Row::new(row_vec).style(style)
})
.collect();
let constraint_for_placeholder = |placeholder: &str| {
if placeholder == "{read}"
|| placeholder == "{marked}"
|| (placeholder == "{flagged}" && !model_data.flagged_articles().is_empty())
{
Constraint::Length(2)
} else if placeholder == "{flagged}" {
Constraint::Length(0)
} else if placeholder == "{age}" {
Constraint::Length(4)
} else if placeholder == "{date}" {
Constraint::Length(config.date_format.len() as u16)
} else if placeholder == "{tag_icons}" {
Constraint::Length(max_tags)
} else {
Constraint::Min(1)
}
};
self.scrollbar_state = self
.scrollbar_state
.content_length(entries.len())
.position(0);
self.table = Table::new(
entries,
placeholders
.iter()
.map(|placeholder| constraint_for_placeholder(placeholder))
.collect::<Vec<Constraint>>(),
)
.row_highlight_style(selected_style);
}
pub(super) fn gen_block(
&self,
config: &Config,
filter_state: &FilterState,
is_focused: bool,
) -> Block<'static> {
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.title_top(self.build_title(filter_state, config))
.title_alignment(ratatui::layout::Alignment::Left)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(if is_focused {
config.theme.border_focused()
} else {
config.theme.border()
})
}
pub(super) fn get_table_state_mut(&mut self) -> &mut TableState {
&mut self.table_state
}
pub(super) fn get_table_state(&self) -> &TableState {
&self.table_state
}
}