File indexing completed on 2024-04-21 05:10:34

0001 /*
0002     This file is part of Akregator.
0003 
0004     SPDX-FileCopyrightText: 2004 Stanislav Karchebny <Stanislav.Karchebny@kdemail.net>
0005     SPDX-FileCopyrightText: 2005-2008 Frank Osterfeld <osterfeld@kde.org>
0006 
0007     SPDX-License-Identifier: GPL-2.0-or-later WITH Qt-Commercial-exception-1.0
0008 */
0009 
0010 #include "articlelistview.h"
0011 #include "actionmanager.h"
0012 #include "akregatorconfig.h"
0013 #include "articlemodel.h"
0014 #include "types.h"
0015 
0016 #include "utils/filtercolumnsproxymodel.h"
0017 
0018 #include <KColorScheme>
0019 #include <KLocalizedString>
0020 #include <QDateTime>
0021 #include <QIcon>
0022 #include <QLocale>
0023 #include <QMenu>
0024 
0025 #include <QApplication>
0026 #include <QContextMenuEvent>
0027 #include <QHeaderView>
0028 #include <QPainter>
0029 #include <QPalette>
0030 #include <QScrollBar>
0031 
0032 #include <cassert>
0033 
0034 using namespace Akregator;
0035 
0036 FilterDeletedProxyModel::FilterDeletedProxyModel(QObject *parent)
0037     : QSortFilterProxyModel(parent)
0038 {
0039     setDynamicSortFilter(true);
0040 }
0041 
0042 bool FilterDeletedProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
0043 {
0044     return !sourceModel()->index(source_row, 0, source_parent).data(ArticleModel::IsDeletedRole).toBool();
0045 }
0046 
0047 SortColorizeProxyModel::SortColorizeProxyModel(QObject *parent)
0048     : QSortFilterProxyModel(parent)
0049     , m_keepFlagIcon(QIcon::fromTheme(QStringLiteral("mail-mark-important")))
0050 {
0051     m_unreadColor = KColorScheme(QPalette::Normal, KColorScheme::View).foreground(KColorScheme::PositiveText).color();
0052     m_newColor = KColorScheme(QPalette::Normal, KColorScheme::View).foreground(KColorScheme::NegativeText).color();
0053 }
0054 
0055 bool SortColorizeProxyModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
0056 {
0057     if (source_parent.isValid()) {
0058         return false;
0059     }
0060 
0061     for (ulong i = 0, total = m_matchers.size(); i < total; ++i) {
0062         if (!static_cast<ArticleModel *>(sourceModel())->rowMatches(source_row, m_matchers[i])) {
0063             return false;
0064         }
0065     }
0066 
0067     return true;
0068 }
0069 
0070 void SortColorizeProxyModel::setFilters(const std::vector<QSharedPointer<const Filters::AbstractMatcher>> &matchers)
0071 {
0072     if (m_matchers == matchers) {
0073         return;
0074     }
0075     m_matchers = matchers;
0076     invalidateFilter();
0077 }
0078 
0079 QVariant SortColorizeProxyModel::data(const QModelIndex &idx, int role) const
0080 {
0081     if (!idx.isValid() || !sourceModel()) {
0082         return {};
0083     }
0084 
0085     const QModelIndex sourceIdx = mapToSource(idx);
0086 
0087     switch (role) {
0088     case Qt::ForegroundRole:
0089         switch (static_cast<ArticleStatus>(sourceIdx.data(ArticleModel::StatusRole).toInt())) {
0090         case Unread:
0091             return Settings::useCustomColors() ? Settings::colorUnreadArticles() : m_unreadColor;
0092         case New:
0093             return Settings::useCustomColors() ? Settings::colorNewArticles() : m_newColor;
0094         case Read:
0095             return QApplication::palette().color(QPalette::Text);
0096         }
0097         break;
0098     case Qt::DecorationRole:
0099         if (sourceIdx.column() == ArticleModel::ItemTitleColumn) {
0100             return sourceIdx.data(ArticleModel::IsImportantRole).toBool() ? m_keepFlagIcon : QVariant();
0101         }
0102         break;
0103     }
0104     return sourceIdx.data(role);
0105 }
0106 
0107 namespace
0108 {
0109 static bool isRead(const QModelIndex &idx)
0110 {
0111     if (!idx.isValid()) {
0112         return false;
0113     }
0114 
0115     return static_cast<ArticleStatus>(idx.data(ArticleModel::StatusRole).toInt()) == Read;
0116 }
0117 }
0118 
0119 void ArticleListView::setArticleModel(ArticleModel *model)
0120 {
0121     if (!model) {
0122         setModel(model);
0123         return;
0124     }
0125 
0126     m_proxy = new SortColorizeProxyModel(model);
0127     m_proxy->setSortRole(ArticleModel::SortRole);
0128     m_proxy->setFilters(m_matchers);
0129     m_proxy->setSourceModel(model);
0130     auto const proxy2 = new FilterDeletedProxyModel(model);
0131     proxy2->setSortRole(ArticleModel::SortRole);
0132     proxy2->setSourceModel(m_proxy);
0133 
0134     connect(model, &QAbstractItemModel::rowsInserted, m_proxy.data(), &QSortFilterProxyModel::invalidate);
0135 
0136     auto const columnsProxy = new FilterColumnsProxyModel(model);
0137     columnsProxy->setSortRole(ArticleModel::SortRole);
0138     columnsProxy->setColumnEnabled(ArticleModel::ItemTitleColumn);
0139     columnsProxy->setColumnEnabled(ArticleModel::FeedTitleColumn);
0140     columnsProxy->setColumnEnabled(ArticleModel::DateColumn);
0141     columnsProxy->setColumnEnabled(ArticleModel::AuthorColumn);
0142     columnsProxy->setSourceModel(proxy2);
0143 
0144     setModel(columnsProxy);
0145     header()->setContextMenuPolicy(Qt::CustomContextMenu);
0146     header()->setSectionResizeMode(QHeaderView::Interactive);
0147 }
0148 
0149 void ArticleListView::showHeaderMenu(const QPoint &pos)
0150 {
0151     if (!model()) {
0152         return;
0153     }
0154 
0155     QPointer<QMenu> menu = new QMenu(this);
0156     menu->setTitle(i18n("Columns"));
0157     menu->setAttribute(Qt::WA_DeleteOnClose);
0158 
0159     const int colCount = model()->columnCount();
0160     int visibleColumns = 0; // number of column currently shown
0161     QAction *visibleColumnsAction = nullptr;
0162     for (int i = 0; i < colCount; ++i) {
0163         QAction *act = menu->addAction(model()->headerData(i, Qt::Horizontal).toString());
0164         act->setCheckable(true);
0165         act->setData(i);
0166         bool sectionVisible = !header()->isSectionHidden(i);
0167         act->setChecked(sectionVisible);
0168         if (sectionVisible) {
0169             ++visibleColumns;
0170             visibleColumnsAction = act;
0171         }
0172     }
0173 
0174     // Avoid that the last shown column is also hidden
0175     if (visibleColumns == 1) {
0176         visibleColumnsAction->setEnabled(false);
0177     }
0178 
0179     QPointer<QObject> that(this);
0180     QAction *const action = menu->exec(header()->mapToGlobal(pos));
0181     if (that && action) {
0182         const int col = action->data().toInt();
0183         if (action->isChecked()) {
0184             header()->showSection(col);
0185         } else {
0186             header()->hideSection(col);
0187         }
0188     }
0189     delete menu;
0190 }
0191 
0192 void ArticleListView::generalPaletteChanged()
0193 {
0194     const QPalette palette = viewport()->palette();
0195     QColor color = palette.text().color();
0196     color.setAlpha(128);
0197     mTextColor = color;
0198 }
0199 
0200 void ArticleListView::paintEvent(QPaintEvent *event)
0201 {
0202     if ((m_matchers.size() != 0) && (model() && model()->rowCount() == 0)) {
0203         QPainter p(viewport());
0204 
0205         QFont font = p.font();
0206         font.setItalic(true);
0207         p.setFont(font);
0208 
0209         if (!mTextColor.isValid()) {
0210             generalPaletteChanged();
0211         }
0212         p.setPen(mTextColor);
0213 
0214         p.drawText(QRect(0, 0, width(), height()), Qt::AlignCenter, i18n("No result found"));
0215     } else {
0216         QTreeView::paintEvent(event);
0217     }
0218 }
0219 
0220 void ArticleListView::saveHeaderSettings()
0221 {
0222     if (model()) {
0223         const QByteArray state = header()->saveState();
0224         if (m_columnMode == FeedMode) {
0225             m_feedHeaderState = state;
0226         } else {
0227             m_groupHeaderState = state;
0228         }
0229     }
0230 
0231     KConfigGroup conf(Settings::self()->config(), QStringLiteral("General"));
0232     conf.writeEntry("ArticleListFeedHeaders", m_feedHeaderState.toBase64());
0233     conf.writeEntry("ArticleListGroupHeaders", m_groupHeaderState.toBase64());
0234 }
0235 
0236 void ArticleListView::loadHeaderSettings()
0237 {
0238     KConfigGroup conf(Settings::self()->config(), QStringLiteral("General"));
0239     m_feedHeaderState = QByteArray::fromBase64(conf.readEntry("ArticleListFeedHeaders").toLatin1());
0240     m_groupHeaderState = QByteArray::fromBase64(conf.readEntry("ArticleListGroupHeaders").toLatin1());
0241 }
0242 
0243 QItemSelectionModel *ArticleListView::articleSelectionModel() const
0244 {
0245     return selectionModel();
0246 }
0247 
0248 const QAbstractItemView *ArticleListView::itemView() const
0249 {
0250     return this;
0251 }
0252 
0253 QAbstractItemView *ArticleListView::itemView()
0254 {
0255     return this;
0256 }
0257 
0258 QPoint ArticleListView::scrollBarPositions() const
0259 {
0260     return {horizontalScrollBar()->value(), verticalScrollBar()->value()};
0261 }
0262 
0263 void ArticleListView::setScrollBarPositions(const QPoint &p)
0264 {
0265     horizontalScrollBar()->setValue(p.x());
0266     verticalScrollBar()->setValue(p.y());
0267 }
0268 
0269 void ArticleListView::setGroupMode()
0270 {
0271     if (m_columnMode == GroupMode) {
0272         return;
0273     }
0274 
0275     if (model()) {
0276         m_feedHeaderState = header()->saveState();
0277     }
0278     m_columnMode = GroupMode;
0279     restoreHeaderState();
0280 }
0281 
0282 void ArticleListView::setFeedMode()
0283 {
0284     if (m_columnMode == FeedMode) {
0285         return;
0286     }
0287 
0288     if (model()) {
0289         m_groupHeaderState = header()->saveState();
0290     }
0291     m_columnMode = FeedMode;
0292     restoreHeaderState();
0293 }
0294 
0295 static int maxDateColumnWidth(const QFontMetrics &fm)
0296 {
0297     int width = 0;
0298     QDateTime date(QDate::currentDate(), QTime(23, 59));
0299     for (int x = 0; x < 10; ++x, date = date.addDays(-1)) {
0300         QString txt = QLatin1Char(' ') + QLocale().toString(date, QLocale::ShortFormat) + QLatin1Char(' ');
0301         width = qMax(width, fm.boundingRect(txt).width());
0302     }
0303     return width;
0304 }
0305 
0306 void ArticleListView::restoreHeaderState()
0307 {
0308     QByteArray state = m_columnMode == GroupMode ? m_groupHeaderState : m_feedHeaderState;
0309     header()->restoreState(state);
0310     if (state.isEmpty()) {
0311         // No state, set a default config:
0312         // - hide the feed column in feed mode (no need to see the same feed title over and over)
0313         // - set the date column wide enough to fit all possible dates
0314         header()->setSectionHidden(ArticleModel::FeedTitleColumn, m_columnMode == FeedMode);
0315         header()->setStretchLastSection(false);
0316         header()->resizeSection(ArticleModel::DateColumn, maxDateColumnWidth(fontMetrics()));
0317         if (model()) {
0318             startResizingTitleColumn();
0319         }
0320     }
0321 
0322     if (header()->sectionSize(ArticleModel::DateColumn) == 1) {
0323         header()->resizeSection(ArticleModel::DateColumn, maxDateColumnWidth(fontMetrics()));
0324     }
0325 }
0326 
0327 void ArticleListView::startResizingTitleColumn()
0328 {
0329     // set the title column to Stretch resize mode so that it adapts to the
0330     // content. finishResizingTitleColumn() will turn the resize mode back to
0331     // Interactive so that the user can still resize the column himself if he
0332     // wants to
0333     header()->setSectionResizeMode(ArticleModel::ItemTitleColumn, QHeaderView::Stretch);
0334     QMetaObject::invokeMethod(this, &ArticleListView::finishResizingTitleColumn, Qt::QueuedConnection);
0335 }
0336 
0337 void ArticleListView::finishResizingTitleColumn()
0338 {
0339     if (QApplication::mouseButtons() != Qt::NoButton) {
0340         // Come back later: user is still resizing the widget
0341         QMetaObject::invokeMethod(this, &ArticleListView::finishResizingTitleColumn, Qt::QueuedConnection);
0342         return;
0343     }
0344     header()->setSectionResizeMode(QHeaderView::Interactive);
0345 }
0346 
0347 ArticleListView::~ArticleListView()
0348 {
0349     saveHeaderSettings();
0350 }
0351 
0352 void ArticleListView::setIsAggregation(bool aggregation)
0353 {
0354     if (aggregation) {
0355         setGroupMode();
0356     } else {
0357         setFeedMode();
0358     }
0359 }
0360 
0361 ArticleListView::ArticleListView(QWidget *parent)
0362     : QTreeView(parent)
0363     , m_columnMode(FeedMode)
0364 {
0365     setSortingEnabled(true);
0366     setAlternatingRowColors(true);
0367     setSelectionMode(QAbstractItemView::ExtendedSelection);
0368     setUniformRowHeights(true);
0369     setRootIsDecorated(false);
0370     setAllColumnsShowFocus(true);
0371     setDragDropMode(QAbstractItemView::DragOnly);
0372 
0373     setMinimumSize(250, 150);
0374     setWhatsThis(
0375         i18n("<h2>Article list</h2>"
0376              "Here you can browse articles from the currently selected feed. "
0377              "You can also manage articles, as marking them as persistent (\"Mark as Important\") or delete them, using the right mouse button menu. "
0378              "To view the web page of the article, you can open the article internally in a tab or in an external browser window."));
0379 
0380     // connect exactly once
0381     disconnect(header(), &QWidget::customContextMenuRequested, this, &ArticleListView::showHeaderMenu);
0382     connect(header(), &QWidget::customContextMenuRequested, this, &ArticleListView::showHeaderMenu);
0383     loadHeaderSettings();
0384 }
0385 
0386 void ArticleListView::mousePressEvent(QMouseEvent *ev)
0387 {
0388     // let's push the event, so we can use currentIndex() to get the newly selected article..
0389     QTreeView::mousePressEvent(ev);
0390 
0391     if (ev->button() == Qt::MiddleButton) {
0392         const QUrl url = currentIndex().data(ArticleModel::LinkRole).toUrl();
0393 
0394         Q_EMIT signalMouseButtonPressed(ev->button(), url);
0395     }
0396 }
0397 
0398 void ArticleListView::contextMenuEvent(QContextMenuEvent *event)
0399 {
0400     QWidget *w = ActionManager::getInstance()->container(QStringLiteral("article_popup"));
0401     auto popup = qobject_cast<QMenu *>(w);
0402     if (popup) {
0403         popup->exec(event->globalPos());
0404     }
0405 }
0406 
0407 void ArticleListView::setModel(QAbstractItemModel *m)
0408 {
0409     const bool groupMode = m_columnMode == GroupMode;
0410 
0411     QAbstractItemModel *const oldModel = model();
0412     if (oldModel) {
0413         const QByteArray state = header()->saveState();
0414         if (groupMode) {
0415             m_groupHeaderState = state;
0416         } else {
0417             m_feedHeaderState = state;
0418         }
0419     }
0420 
0421     QTreeView::setModel(m);
0422 
0423     if (m) {
0424         sortByColumn(ArticleModel::DateColumn, Qt::DescendingOrder);
0425         restoreHeaderState();
0426 
0427         // Ensure at least one column is visible
0428         if (header()->hiddenSectionCount() == header()->count()) {
0429             header()->showSection(ArticleModel::ItemTitleColumn);
0430         }
0431     }
0432 }
0433 
0434 void ArticleListView::slotClear()
0435 {
0436     setModel(nullptr);
0437 }
0438 
0439 void ArticleListView::slotPreviousArticle()
0440 {
0441     if (!model()) {
0442         return;
0443     }
0444     Q_EMIT userActionTakingPlace();
0445     const QModelIndex idx = currentIndex();
0446     const int newRow = qMax(0, (idx.isValid() ? idx.row() : model()->rowCount()) - 1);
0447     const QModelIndex newIdx = idx.isValid() ? idx.sibling(newRow, 0) : model()->index(newRow, 0);
0448     selectIndex(newIdx);
0449 }
0450 
0451 void ArticleListView::slotNextArticle()
0452 {
0453     if (!model()) {
0454         return;
0455     }
0456 
0457     Q_EMIT userActionTakingPlace();
0458     const QModelIndex idx = currentIndex();
0459     const int newRow = idx.isValid() ? (idx.row() + 1) : 0;
0460     const QModelIndex newIdx = model()->index(qMin(newRow, model()->rowCount() - 1), 0);
0461     selectIndex(newIdx);
0462 }
0463 
0464 void ArticleListView::slotNextUnreadArticle()
0465 {
0466     if (!model()) {
0467         return;
0468     }
0469 
0470     const int rowCount = model()->rowCount();
0471     const int startRow = qMin(rowCount - 1, (currentIndex().isValid() ? currentIndex().row() + 1 : 0));
0472 
0473     int i = startRow;
0474     bool foundUnread = false;
0475 
0476     do {
0477         if (!::isRead(model()->index(i, 0))) {
0478             foundUnread = true;
0479         } else {
0480             i = (i + 1) % rowCount;
0481         }
0482     } while (!foundUnread && i != startRow);
0483 
0484     if (foundUnread) {
0485         selectIndex(model()->index(i, 0));
0486     }
0487 }
0488 
0489 void ArticleListView::selectIndex(const QModelIndex &idx)
0490 {
0491     if (!idx.isValid()) {
0492         return;
0493     }
0494     setCurrentIndex(idx);
0495     scrollTo(idx, PositionAtCenter);
0496 }
0497 
0498 void ArticleListView::slotPreviousUnreadArticle()
0499 {
0500     if (!model()) {
0501         return;
0502     }
0503 
0504     const int rowCount = model()->rowCount();
0505     const int startRow = qMax(0, (currentIndex().isValid() ? currentIndex().row() : rowCount) - 1);
0506 
0507     int i = startRow;
0508     bool foundUnread = false;
0509 
0510     do {
0511         if (!::isRead(model()->index(i, 0))) {
0512             foundUnread = true;
0513         } else {
0514             i = i > 0 ? i - 1 : rowCount - 1;
0515         }
0516     } while (!foundUnread && i != startRow);
0517 
0518     if (foundUnread) {
0519         selectIndex(model()->index(i, 0));
0520     }
0521 }
0522 
0523 void ArticleListView::forceFilterUpdate()
0524 {
0525     if (m_proxy) {
0526         m_proxy->invalidate();
0527     }
0528 }
0529 
0530 void ArticleListView::setFilters(const std::vector<QSharedPointer<const Filters::AbstractMatcher>> &matchers)
0531 {
0532     if (m_matchers == matchers) {
0533         return;
0534     }
0535     m_matchers = matchers;
0536     if (m_proxy) {
0537         m_proxy->setFilters(matchers);
0538     }
0539 }
0540 
0541 #include "moc_articlelistview.cpp"