mod feed_list_item;
mod model;
mod view;
pub mod prelude {
pub use super::FeedList;
}
use feed_list_item::FeedListItem;
use log::info;
use news_flash::models::{CategoryID, PluginCapabilities, UnifiedMapping, Url};
use ratatui::layout::Position;
use tui_tree_widget::TreeItem;
use crate::{
prelude::*,
ui::{
feeds_list::{
model::{FeedListModelData, FeedOrCategory},
view::FeedListViewData,
},
tooltip,
},
};
use std::{sync::Arc, time::Duration};
use tokio::{sync::mpsc::UnboundedSender, time::Instant};
pub struct FeedList {
config: Arc<Config>,
message_sender: UnboundedSender<Message>,
view_data: FeedListViewData,
model_data: FeedListModelData,
is_focused: bool,
last_sync: Instant,
search_term: Option<SearchTerm>,
}
impl FeedList {
pub fn new(
config: Arc<Config>,
news_flash_utils: Arc<NewsFlashUtils>,
message_sender: UnboundedSender<Message>,
) -> Self {
Self {
config: config.clone(),
message_sender,
model_data: FeedListModelData::new(news_flash_utils.clone()),
view_data: FeedListViewData::new(&config),
is_focused: false,
last_sync: Instant::now(),
search_term: None,
}
}
pub(super) fn update_tooltip(&self) -> color_eyre::Result<()> {
if let Some(item) = self.selected() {
tooltip(
&self.message_sender,
item.to_tooltip(&self.config).as_str(),
TooltipFlavor::Info,
)?;
}
Ok(())
}
pub(super) fn selected(&self) -> Option<FeedListItem> {
self.view_data.tree_state().selected().last().cloned()
}
fn generate_articles_selected_command(&self) -> color_eyre::Result<()> {
if let Some(selected) = self.selected() {
match selected.try_into() {
Ok(article_filter) => {
self.message_sender
.send(Message::Event(Event::ArticlesSelected(article_filter)))?;
}
Err(err) => {
tooltip(
&self.message_sender,
err.to_string().as_str(),
TooltipFlavor::Warning,
)?;
}
}
};
Ok(())
}
fn set_current_read(&mut self) -> color_eyre::Result<()> {
use FeedListItem::*;
if let Some(selected) = self.selected().as_ref() {
match selected {
All => self.model_data.set_all_read()?,
Feed(feed) => self.model_data.set_feed_read(feed.feed_id.clone())?,
Category(category) => self
.model_data
.set_category_read(category.category_id.clone())?,
Tag(tag) => self.model_data.set_tag_read(tag.tag_id.clone())?,
Tags => {}
Categories | Query(_) => {
self.message_sender.send(Message::Command(Command::In(
Panel::ArticleList,
Box::new(Command::ActionSetRead(ActionScope::All)),
)))?;
}
}
tooltip(
&self.message_sender,
format!("set articles in {} to read", selected).as_str(),
TooltipFlavor::Info,
)?;
}
Ok(())
}
async fn rename_current(&mut self, name: String) -> color_eyre::Result<()> {
use FeedListItem::*;
if let Some(selected) = self.selected().as_ref() {
match selected {
not_supported @ (All | Tags | Query(_) | Categories) => {
return tooltip(
&self.message_sender,
format!("renaming not supported for {not_supported}").as_str(),
TooltipFlavor::Warning,
);
}
Feed(feed) => self
.model_data
.rename_feed(feed.feed_id.clone(), name.clone())?,
Category(category) => {
if !self
.model_data
.features()
.await?
.contains(PluginCapabilities::MODIFY_CATEGORIES)
{
return tooltip(
&self.message_sender,
"provider does not support modifying categories",
TooltipFlavor::Error,
);
} else {
self.model_data
.rename_category(category.category_id.clone(), name.clone())?;
}
}
Tag(tag) => match self.model_data.get_tag_by_label(&name) {
Some(_) => {
return tooltip(
&self.message_sender,
format!("tag with name #{name} already exists").as_str(),
TooltipFlavor::Error,
);
}
None => self
.model_data
.edit_tag(tag.tag_id.clone(), name.clone(), None)?,
},
}
tooltip(
&self.message_sender,
format!("renaming {} to {}", selected, name).as_str(),
TooltipFlavor::Info,
)?;
}
Ok(())
}
async fn remove_current(&mut self, remove_children: bool) -> color_eyre::Result<()> {
use FeedListItem::*;
if let Some(selected) = self.selected().as_ref() {
match selected {
not_supported @ (All | Tags | Query(_) | Categories) => {
tooltip(
&self.message_sender,
format!("removing not supported for {not_supported}").as_str(),
TooltipFlavor::Warning,
)?;
return Ok(());
}
Feed(feed) => {
if !self
.model_data
.features()
.await?
.contains(PluginCapabilities::ADD_REMOVE_FEEDS)
{
tooltip(
&self.message_sender,
"provider does not support modifying feeds",
TooltipFlavor::Error,
)?;
} else {
self.model_data.remove_feed(feed.feed_id.clone())?;
}
}
Category(category) => {
if !self
.model_data
.features()
.await?
.contains(PluginCapabilities::MODIFY_CATEGORIES)
{
tooltip(
&self.message_sender,
"provider does not support modifying categories",
TooltipFlavor::Error,
)?;
} else {
self.model_data
.remove_category(category.category_id.to_owned(), remove_children)?;
}
}
Tag(tag) => self.model_data.remove_tag(tag.tag_id.to_owned())?,
}
tooltip(
&self.message_sender,
format!("removing {}", selected).as_str(),
TooltipFlavor::Info,
)?;
}
Ok(())
}
fn maybe_selected_category(&self) -> Option<CategoryID> {
let selected = self.view_data.tree_state().selected();
self.selected()
.and_then(|feed_list_item| match feed_list_item {
FeedListItem::Category(category) => Some(category.category_id.to_owned()),
_ => None,
})
.or_else(|| {
selected
.get(selected.len().saturating_sub(2))
.map(|second_to_last| match second_to_last {
FeedListItem::Category(category) => Some(category.category_id.to_owned()),
_ => None,
})?
})
}
async fn add_category(&self, name: &String) -> color_eyre::Result<()> {
let features = self.model_data.features().await?;
if !features.contains(
PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_CATEGORIES,
) {
tooltip(
&self.message_sender,
"cannot add category: provider does not support modification of categories",
TooltipFlavor::Error,
)?;
}
let mut category_id = self.maybe_selected_category();
if category_id.is_some() && !features.intersects(PluginCapabilities::SUPPORT_SUBCATEGORIES)
{
category_id = None;
tooltip(
&self.message_sender,
"provider does not support subcategories: creating at root level",
TooltipFlavor::Warning,
)?;
} else {
tooltip(
&self.message_sender,
format!("adding category {}...", name).as_str(),
TooltipFlavor::Info,
)?;
}
self.model_data
.add_category(name.clone(), category_id)
.await
}
async fn check_tag_capability(&self) -> color_eyre::Result<bool> {
if !self
.model_data
.features()
.await?
.contains(PluginCapabilities::SUPPORT_TAGS)
{
tooltip(
&self.message_sender,
"this provider does not support tags",
TooltipFlavor::Error,
)?;
return Ok(false);
}
Ok(true)
}
async fn change_feed_url(&self, url: &Url) -> color_eyre::Result<()> {
if !self
.model_data
.features()
.await?
.contains(PluginCapabilities::EDIT_FEED_URLS)
{
tooltip(
&self.message_sender,
"provider does not support changeing the URL of a feed",
TooltipFlavor::Error,
)?;
return Ok(());
}
let Some(FeedListItem::Feed(feed)) = self.selected() else {
tooltip(
&self.message_sender,
"no feed selected",
TooltipFlavor::Warning,
)?;
return Ok(());
};
self.model_data
.change_feed_url(feed.feed_id.to_owned(), url.to_string())?;
Ok(())
}
fn yank_feed_or_category(&mut self) -> color_eyre::Result<()> {
match self.selected() {
Some(FeedListItem::Feed(feed)) => self.view_data.yank_feed_or_category(
FeedOrCategory::Feed(feed.feed_id.to_owned()),
&self.model_data,
),
Some(FeedListItem::Category(category)) => self.view_data.yank_feed_or_category(
FeedOrCategory::Category(category.category_id.to_owned()),
&self.model_data,
),
_ => {
tooltip(
&self.message_sender,
"can only yank feed or category",
TooltipFlavor::Warning,
)?;
}
}
Ok(())
}
fn paste_feed_or_category(&mut self, position: PastePosition) -> color_eyre::Result<()> {
use FeedListItem::*;
let mut yanked_unified_mapping = self.view_data.take_yanked_unified_mapping();
let Some(to_unified_mapping) = yanked_unified_mapping.as_mut() else {
return tooltip(
&self.message_sender,
"cannot paste as there is no item yanked",
TooltipFlavor::Warning,
);
};
let from_unified_mapping = match to_unified_mapping {
UnifiedMapping::Category(category_mapping) => {
UnifiedMapping::Category(category_mapping.clone())
}
UnifiedMapping::Feed(feed_mapping) => UnifiedMapping::Feed(feed_mapping.clone()),
};
let Some((mut new_parent_category_id, dest_unified_mapping)) = (match self.selected() {
Some(Category(category)) => self
.model_data
.category_mapping_for_category()
.get(&category.category_id)
.cloned()
.map(|mapping| {
(
mapping.parent_id.to_owned(),
UnifiedMapping::Category(mapping),
)
}),
Some(Feed(feed)) => self
.model_data
.feed_mapping_for_feed()
.get(&feed.feed_id)
.cloned()
.map(|mapping| {
(
mapping.category_id.to_owned(),
UnifiedMapping::Feed(mapping),
)
}),
_ => {
self.view_data
.set_yanked_unified_mapping(yanked_unified_mapping);
return tooltip(
&self.message_sender,
"pasting does not work here",
TooltipFlavor::Warning,
);
}
}) else {
self.view_data
.set_yanked_unified_mapping(yanked_unified_mapping);
return tooltip(
&self.message_sender,
"selected item has no category mapping",
TooltipFlavor::Error,
);
};
to_unified_mapping.set_sort_index(
dest_unified_mapping
.sort_index()
.unwrap_or(i32::MAX)
.saturating_add(match position {
PastePosition::Before => -1,
PastePosition::After => 1,
}),
);
if let UnifiedMapping::Category(dest_category_mapping) = dest_unified_mapping
&& matches!(position, PastePosition::After)
{
new_parent_category_id = dest_category_mapping.category_id;
to_unified_mapping.set_sort_index(0);
}
match (from_unified_mapping, to_unified_mapping) {
(UnifiedMapping::Feed(from_feed_mapping), UnifiedMapping::Feed(to_feed_mapping)) => {
to_feed_mapping.category_id = new_parent_category_id;
self.model_data
.move_feed(from_feed_mapping, to_feed_mapping.to_owned())?;
}
(UnifiedMapping::Category(_), UnifiedMapping::Category(to_category_mapping)) => {
to_category_mapping.parent_id = new_parent_category_id;
self.model_data
.move_category(to_category_mapping.to_owned())?;
}
_ => unreachable!(),
}
Ok(())
}
fn expand_scope(&mut self, scope: ArticleScope) {
let roots = self.view_data.tree_items().clone();
for item in roots {
self.expand_recursively(scope, &mut Vec::default(), &item);
}
}
fn expand_recursively<'a>(
&mut self,
scope: ArticleScope,
path: &mut Vec<&'a FeedListItem>,
item: &'a TreeItem<'a, FeedListItem>,
) {
path.push(item.identifier());
self.expand(scope, path);
for child_item in item.children() {
self.expand_recursively(scope, path, child_item);
}
path.pop();
}
fn expand(&mut self, scope: ArticleScope, path: &Vec<&FeedListItem>) {
let Some(item) = path.last() else {
return;
};
use ArticleScope as S;
let expand = match item {
FeedListItem::Category(category_id) => match scope {
S::All => true,
S::Unread => self
.model_data
.unread_count_for_feed_or_category()
.get(&FeedOrCategory::Category(
category_id.as_ref().category_id.to_owned(),
))
.map(|count| *count > 0)
.unwrap_or(false),
S::Marked => self
.model_data
.marked_count_for_feed_or_category()
.get(&FeedOrCategory::Category(
category_id.as_ref().category_id.to_owned(),
))
.map(|count| *count > 0)
.unwrap_or(false),
},
_ => false,
};
if expand {
let path = path.iter().cloned().cloned().collect::<Vec<FeedListItem>>();
self.view_data.tree_state_mut().open(path);
}
}
fn search_next(&mut self, reverse: bool) -> color_eyre::Result<()> {
if self.search_term.is_none() {
tooltip(
&self.message_sender,
"no search term",
TooltipFlavor::Warning,
)?;
}
if self.view_data.found_items().is_empty() {
tooltip(
&self.message_sender,
"no matching item",
TooltipFlavor::Warning,
)?;
return Ok(());
}
let selected = self.view_data.tree_state().selected();
let mut paths = self.view_data.paths().to_vec();
if reverse {
paths.reverse();
}
let found_path = paths
.iter()
.skip_while(|path| **path != selected)
.skip(1)
.find(|path| self.view_data.found_paths().contains(*path))
.cloned();
let found_path = match found_path {
Some(found_path) => Some(found_path),
None => {
tooltip(
&self.message_sender,
if reverse {
"top reached, starting from bottom"
} else {
"bottom reached, starting from top"
},
TooltipFlavor::Info,
)?;
paths.first().cloned()
}
};
if let Some(found_path) = found_path {
let parent = found_path.split_last().map(|split| split.1.to_vec());
if let Some(parent) = parent {
self.view_data.tree_state_mut().open(parent);
}
self.view_data.tree_state_mut().select(found_path.to_vec());
self.generate_articles_selected_command()?;
}
Ok(())
}
fn item_has_unread(&self, item: &FeedListItem) -> bool {
match item {
FeedListItem::Feed(feed) => self
.model_data
.unread_count_for_feed_or_category()
.get(&FeedOrCategory::Feed(feed.feed_id.clone()))
.map(|count| *count > 0)
.unwrap_or(false),
FeedListItem::Category(category) => self
.model_data
.unread_count_for_feed_or_category()
.get(&FeedOrCategory::Category(category.category_id.clone()))
.map(|count| *count > 0)
.unwrap_or(false),
FeedListItem::Tag(_)
| FeedListItem::All
| FeedListItem::Categories
| FeedListItem::Tags
| FeedListItem::Query(_) => false,
}
}
fn select_next_unread(&mut self, handle_here: bool) -> color_eyre::Result<()> {
let selected = self.view_data.tree_state().selected();
let paths = self.view_data.paths().to_vec();
if !matches!(
selected.last(),
Some(FeedListItem::Category(..)) | Some(FeedListItem::Feed(..))
) {
return Ok(());
}
let found_path = paths
.iter()
.skip_while(|path| **path != selected)
.find(|path| {
path.last()
.map(|item| self.item_has_unread(item))
.unwrap_or(false)
})
.cloned();
if let Some(found_path) = found_path {
let parent = found_path.split_last().map(|split| split.1.to_vec());
if let Some(parent) = parent {
self.view_data.tree_state_mut().open(parent);
}
self.view_data.tree_state_mut().select(found_path.to_vec());
self.generate_articles_selected_command()?;
if !handle_here {
self.message_sender.send(Message::Command(Command::In(
Panel::ArticleList,
Box::new(Command::SelectNextUnread),
)))?;
}
} else {
tooltip(
&self.message_sender,
"no unread items",
TooltipFlavor::Warning,
)?;
}
Ok(())
}
async fn sort(&self) -> color_eyre::Result<()> {
self.model_data.sort().await
}
}
impl MessageReceiver for FeedList {
async fn process_command(&mut self, message: &Message) -> color_eyre::Result<()> {
if matches!(message, Message::Event(Event::Tick)) {
if let Some(sync_interval) = self.config.sync_every_minutes
&& self
.last_sync
.checked_add(Duration::from_mins(sync_interval))
.map(|time_to_sync| Instant::now() > time_to_sync)
.unwrap_or(false)
{
self.message_sender
.send(Message::Command(Command::FeedListSync))?;
self.last_sync = Instant::now();
}
return Ok(());
}
let selected_before = self
.view_data
.get_selection_with_index(self.view_data.tree_state().selected());
let mut model_needs_update = false;
let mut view_needs_update = false;
let mut selection_changed = false;
let mut handle_here = false;
let mut enforce_articles_selected = false;
if let Message::Command(command) = message {
use Command as C;
let mut handle_command = false;
let Some(command) = (match command {
C::In(Panel::FeedList, command) => {
handle_command = true;
handle_here = true;
Some(*command.to_owned())
}
C::In(..) => None,
command => {
handle_command = self.is_focused;
Some(command.to_owned())
}
}) else {
return Ok(());
};
match command {
C::NavigateUp if handle_command => {
self.view_data.tree_state_mut().key_up();
selection_changed = true;
}
C::NavigateDown if handle_command => {
self.view_data.tree_state_mut().key_down();
selection_changed = true;
}
C::NavigateRight if handle_command => {
self.view_data.tree_state_mut().key_right();
selection_changed = true;
}
C::NavigateLeft if handle_command => {
self.view_data.tree_state_mut().key_left();
selection_changed = true;
}
C::NavigateFirst if handle_command => {
self.view_data.tree_state_mut().select_first();
selection_changed = true;
}
C::NavigateLast if handle_command => {
self.view_data.tree_state_mut().select_last();
selection_changed = true;
}
C::SelectNextUnread if handle_command => {
self.select_next_unread(handle_here)?;
selection_changed = true;
}
C::FeedListToggleExpand => {
self.view_data.tree_state_mut().toggle_selected();
selection_changed = true;
}
C::FeedListExpand => {
let selected = self.view_data.tree_state().selected().to_vec();
self.view_data.tree_state_mut().open(selected);
selection_changed = true;
}
C::FeedListExpandCategories(scope) => {
self.expand_scope(scope);
selection_changed = true;
}
C::FeedListCollapse => {
let selected = self.view_data.tree_state().selected().to_vec();
self.view_data.tree_state_mut().close(&selected);
selection_changed = true;
}
C::FeedListCollapseAll => {
self.view_data.tree_state_mut().close_all();
}
C::NavigatePageDown if handle_command => {
self.view_data
.tree_state_mut()
.scroll_down(self.config.input_config.scroll_amount);
selection_changed = true;
}
C::NavigatePageUp if handle_command => {
self.view_data
.tree_state_mut()
.scroll_up(self.config.input_config.scroll_amount);
selection_changed = true;
}
C::Show(scope) if handle_command => {
*self.view_data.scope_mut() = scope;
view_needs_update = true;
}
C::ActionSetRead(action_scope) if handle_command => {
match action_scope {
ActionScope::All => self.model_data.set_all_read()?,
ActionScope::Current => self.set_current_read()?,
query_scope @ ActionScope::Query(_) => {
self.message_sender.send(Message::Command(Command::In(
Panel::ArticleList,
Box::new(Command::ActionSetRead(query_scope.to_owned())),
)))?;
}
ActionScope::Above | ActionScope::Below => tooltip(
&self.message_sender,
"this is not supported in the feed list",
TooltipFlavor::Warning,
)?,
}
}
C::FeedListFeedAdd(url, name) => {
let features = self.model_data.features().await?;
if !features.contains(PluginCapabilities::ADD_REMOVE_FEEDS) {
tooltip(
&self.message_sender,
"provider does not support adding feeds",
TooltipFlavor::Error,
)?;
} else {
self.model_data.add_feed(
url.as_ref()
.ok_or(color_eyre::eyre::eyre!("no url defined"))?
.to_owned(),
name.clone(),
self.maybe_selected_category(),
)?;
tooltip(&self.message_sender, "adding feed...", TooltipFlavor::Info)?;
}
}
C::FeedListCategoryAdd(name) => {
self.add_category(&name).await?;
}
C::FeedListFeedChangeUrl(url) => {
self.change_feed_url(
url.as_ref()
.ok_or(color_eyre::eyre::eyre!("no url defined"))?,
)
.await?
}
C::TagAdd(name, color) if self.check_tag_capability().await? => {
if self.model_data.tags().iter().any(|tag| *tag.label == *name) {
tooltip(
&self.message_sender,
format!("tag with name {} already exists", name).as_str(),
TooltipFlavor::Error,
)?;
} else {
self.model_data.add_tag(&name, &color).await?;
}
}
C::FeedListTagChangeColor(color) => match self.selected() {
Some(FeedListItem::Tag(tag)) => {
self.model_data.edit_tag(
tag.tag_id.to_owned(),
tag.label.to_owned(),
Some(color.to_owned()),
)?;
}
_ => tooltip(
&self.message_sender,
"select a tag to change its color",
TooltipFlavor::Error,
)?,
},
C::FeedListSync => {
tooltip(&self.message_sender, "syncing all", TooltipFlavor::Info)?;
self.model_data.sync()?;
}
C::FeedListRenameEntity(name) => {
self.rename_current(name.to_owned()).await?;
}
C::FeedListRemoveEntity => {
self.remove_current(false).await?;
}
C::FeedListRemoveEntityWithChildren => {
self.remove_current(true).await?;
}
C::FeedListYankFeedOrCategory => {
self.yank_feed_or_category()?;
view_needs_update = true;
}
C::FeedListPasteFeedOrCategory(position) => {
self.paste_feed_or_category(position)?;
}
C::FeedListSort => {
self.sort().await?;
model_needs_update = true;
}
C::Refresh => {
model_needs_update = true;
enforce_articles_selected = true;
}
C::InputSearch if handle_command => {
self.message_sender
.send(Message::Command(Command::CommandLineOpen(Some(
"search".to_owned(),
))))?;
}
C::Search(Some(search_term)) if handle_command => {
info!("searching in feed list for {search_term}");
self.search_term = Some(search_term);
view_needs_update = true;
self.message_sender.send(Message::Command(Command::In(
Panel::FeedList,
Box::new(Command::SearchNext),
)))?;
}
C::SearchNext if handle_command => {
self.search_next(false)?;
}
C::SearchPrevious if handle_command => {
self.search_next(true)?;
}
_ => {}
}
};
if let Message::Event(event) = message {
use Event as E;
match event {
E::ApplicationStarted => {
model_needs_update = true;
}
E::ApplicationStateChanged(state) => {
self.is_focused = *state == AppState::FeedSelection;
}
E::AsyncFeedAddFinished(feed) => {
tooltip(
&self.message_sender,
format!("successfully added feed {}, fetching feed now", feed.label)
.as_str(),
TooltipFlavor::Info,
)?;
self.model_data.fetch_feed(feed.feed_id.to_owned())?;
model_needs_update = true;
}
E::AsyncFeedFetchFinished(..) => {
self.message_sender
.send(Message::Command(Command::FeedListSync))?;
}
E::AsyncSyncFinished(_) => {
model_needs_update = true;
}
E::AsyncCategoryAddFinished(category) => {
tooltip(
&self.message_sender,
format!("successfully added category {}", category.label).as_str(),
TooltipFlavor::Info,
)?;
model_needs_update = true;
}
E::AsyncFeedMoveFinished | E::AsyncCategoryMoveFinished => {
tooltip(&self.message_sender, "move successful", TooltipFlavor::Info)?;
model_needs_update = true;
}
E::AsyncRenameFeedFinished(_)
| E::AsyncCategoryRenameFinished(_)
| E::AsyncTagEditFinished(_) => {
tooltip(
&self.message_sender,
"successfully changed",
TooltipFlavor::Info,
)?;
model_needs_update = true;
}
E::AsyncFeedRemoveFinished
| E::AsyncCategoryRemoveFinished
| E::AsyncTagRemoveFinished => {
tooltip(
&self.message_sender,
"removal successful",
TooltipFlavor::Info,
)?;
model_needs_update = true;
}
E::MouseFeedClick(col, row) => {
let pos = Position::new(*col, *row);
if self.view_data.tree_state_mut().click_at(pos) {
selection_changed = true;
}
}
E::MouseScrollDown(Panel::FeedList) => {
self.view_data.tree_state_mut().scroll_down(1);
}
E::MouseScrollUp(Panel::FeedList) => {
self.view_data.tree_state_mut().scroll_up(1);
}
event if event.caused_model_update() => model_needs_update = true,
_ => {}
}
}
if model_needs_update {
self.model_data.update().await?;
self.view_data
.update(&self.config, &self.model_data, &self.search_term)
.await?;
selection_changed = self.view_data.ensure_sensible_selection(&selected_before);
self.message_sender
.send(Message::Command(Command::Redraw))?;
} else if view_needs_update {
self.view_data
.update(&self.config, &self.model_data, &self.search_term)
.await?;
selection_changed = self.view_data.ensure_sensible_selection(&selected_before);
self.message_sender
.send(Message::Command(Command::Redraw))?;
}
if enforce_articles_selected || selection_changed {
self.update_tooltip()?;
self.generate_articles_selected_command()?;
}
Ok(())
}
}