Refactor getting of selected message tree
[trojita.git] / src / Gui / MsgListView.cpp
blob7562b40b2e4d9b377518af2ecfb45797bf0eac19
1 /* Copyright (C) 2006 - 2014 Jan Kundrát <jkt@flaska.net>
3 This file is part of the Trojita Qt IMAP e-mail client,
4 http://trojita.flaska.net/
6 This program is free software; you can redistribute it and/or
7 modify it under the terms of the GNU General Public License as
8 published by the Free Software Foundation; either version 2 of
9 the License or (at your option) version 3 or any later version
10 accepted by the membership of KDE e.V. (or its successor approved
11 by the membership of KDE e.V.), which shall act as a proxy
12 defined in Section 14 of version 3 of the license.
14 This program is distributed in the hope that it will be useful,
15 but WITHOUT ANY WARRANTY; without even the implied warranty of
16 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 GNU General Public License for more details.
19 You should have received a copy of the GNU General Public License
20 along with this program. If not, see <http://www.gnu.org/licenses/>.
22 #include "MsgListView.h"
24 #include <QAction>
25 #include <QApplication>
26 #include <QDesktopWidget>
27 #include <QDrag>
28 #include <QFontMetrics>
29 #include <QHeaderView>
30 #include <QKeyEvent>
31 #include <QPainter>
32 #include <QSignalMapper>
33 #include <QTimer>
34 #include "MsgItemDelegate.h"
35 #include "Imap/Model/MsgListModel.h"
36 #include "Imap/Model/PrettyMsgListModel.h"
37 #include "Imap/Model/ThreadingMsgListModel.h"
39 namespace Gui
42 MsgListView::MsgListView(QWidget *parent, Imap::Mailbox::FavoriteTagsModel *m_favoriteTagsModel):
43 QTreeView(parent), m_autoActivateAfterKeyNavigation(true), m_autoResizeSections(true)
45 connect(header(), &QHeaderView::geometriesChanged, this, &MsgListView::slotFixSize);
46 connect(this, &QTreeView::expanded, this, &MsgListView::slotExpandWholeSubtree);
47 connect(header(), &QHeaderView::sectionCountChanged, this, &MsgListView::slotUpdateHeaderActions);
48 header()->setContextMenuPolicy(Qt::ActionsContextMenu);
49 headerFieldsMapper = new QSignalMapper(this);
50 connect(headerFieldsMapper, static_cast<void (QSignalMapper::*)(int)>(&QSignalMapper::mapped), this, &MsgListView::slotHeaderSectionVisibilityToggled);
52 setUniformRowHeights(true);
53 setAllColumnsShowFocus(true);
54 setSelectionMode(ExtendedSelection);
55 setDragEnabled(true);
56 setRootIsDecorated(false);
57 // Some subthreads might be huuuuuuuuuuge, so prevent indenting them too heavily
58 setIndentation(15);
60 setItemDelegate(new MsgItemDelegate(this, m_favoriteTagsModel));
62 setSortingEnabled(true);
63 // By default, we don't do any sorting
64 header()->setSortIndicator(-1, Qt::AscendingOrder);
66 m_naviActivationTimer = new QTimer(this);
67 m_naviActivationTimer->setSingleShot(true);
68 connect(m_naviActivationTimer, &QTimer::timeout, this, &MsgListView::slotCurrentActivated);
71 // left might collapse a thread, question is whether ending there (on closing the thread) should be
72 // taken as mail loading request (i don't think so, but it's sth. that needs to be figured over time)
73 // NOTICE: reasonably Triggers should be a (non strict) subset of Blockers (user changed his mind)
75 // the list of key events which pot. lead to loading a new message.
76 static QList<int> gs_naviActivationTriggers = QList<int>() << Qt::Key_Up << Qt::Key_Down << Qt::Key_Right << Qt::Key_Left
77 << Qt::Key_PageUp << Qt::Key_PageDown
78 << Qt::Key_Home << Qt::Key_End;
79 // the list of key events which cancel naviActivationTrigger induced action.
80 static QList<int> gs_naviActivationBlockers = QList<int>() << Qt::Key_Up << Qt::Key_Down << Qt::Key_Left
81 << Qt::Key_PageUp << Qt::Key_PageDown
82 << Qt::Key_Home << Qt::Key_End;
85 void MsgListView::keyPressEvent(QKeyEvent *ke)
87 if (gs_naviActivationBlockers.contains(ke->key()))
88 m_naviActivationTimer->stop();
89 QTreeView::keyPressEvent(ke);
92 void MsgListView::keyReleaseEvent(QKeyEvent *ke)
94 if (ke->modifiers() == Qt::NoModifier && gs_naviActivationTriggers.contains(ke->key()))
95 m_naviActivationTimer->start(150); // few ms for the user to re-orientate. 150ms is not much
96 QTreeView::keyReleaseEvent(ke);
99 bool MsgListView::event(QEvent *event)
101 if (event->type() == QEvent::ShortcutOverride
102 && !gs_naviActivationBlockers.contains(static_cast<QKeyEvent*>(event)->key())
103 && m_naviActivationTimer->isActive()) {
104 // Make sure that the delayed timer is broken ASAP when the key looks like something which might possibly be a shortcut
105 m_naviActivationTimer->stop();
106 slotCurrentActivated();
108 return QTreeView::event(event);
111 void MsgListView::slotCurrentActivated()
113 if (currentIndex().isValid() && m_autoActivateAfterKeyNavigation) {
114 // The "current index" is the one with that funny dot which only triggers the read/unread status toggle.
115 // If we don't do anything, subsequent pressing of key_up or key_down will move the cursor up/down one row
116 // while preserving the column which will lead to toggling the read/unread state of *that* message.
117 // That's unexpected; the key shall just move the cursor and change the current message.
118 emit activated(currentIndex().sibling(currentIndex().row(), Imap::Mailbox::MsgListModel::SUBJECT));
122 int MsgListView::sizeHintForColumn(int column) const
124 QFont boldFont = font();
125 boldFont.setBold(true);
126 QFontMetrics metric(boldFont);
127 switch (column) {
128 case Imap::Mailbox::MsgListModel::SEEN:
129 return 0;
130 case Imap::Mailbox::MsgListModel::FLAGGED:
131 case Imap::Mailbox::MsgListModel::ATTACHMENT:
132 return style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, nullptr);
133 case Imap::Mailbox::MsgListModel::SUBJECT:
134 return metric.size(Qt::TextSingleLine, QStringLiteral("Blesmrt Trojita Foo Bar Random Text")).width();
135 case Imap::Mailbox::MsgListModel::FROM:
136 case Imap::Mailbox::MsgListModel::TO:
137 case Imap::Mailbox::MsgListModel::CC:
138 case Imap::Mailbox::MsgListModel::BCC:
139 return metric.size(Qt::TextSingleLine, QStringLiteral("Blesmrt Trojita")).width();
140 case Imap::Mailbox::MsgListModel::DATE:
141 case Imap::Mailbox::MsgListModel::RECEIVED_DATE:
142 return metric.size(Qt::TextSingleLine,
143 //: Translators: use a text which is returned for e-mails older than one day but newer than one week
144 //: (see UiUtils::Formatting::prettyDate() for the string formats); the idea here
145 //: is to have a text which is "wide enough" in a typical UI font.
146 //: The English version uses "Mon" because of the M letter; you should use something similar.
147 tr("Mon 10")).width();
148 case Imap::Mailbox::MsgListModel::SIZE:
149 return metric.size(Qt::TextSingleLine, tr("88.8 kB")).width();
150 default:
151 return QTreeView::sizeHintForColumn(column);
155 /** @short Reimplemented to show custom pixmap during drag&drop
157 Qt's model-view classes don't provide any means of interfering with the
158 QDrag's pixmap so we just rip off QAbstractItemView::startDrag and provide
159 our own QPixmap.
161 void MsgListView::startDrag(Qt::DropActions supportedActions)
163 // indexes for column 0, i.e. subject
164 QModelIndexList baseIndexes;
166 Q_FOREACH(const QModelIndex &index, selectedIndexes()) {
167 if (!(model()->flags(index) & Qt::ItemIsDragEnabled))
168 continue;
169 if (index.column() == Imap::Mailbox::MsgListModel::SUBJECT)
170 baseIndexes << index;
173 if (!baseIndexes.isEmpty()) {
174 QMimeData *data = model()->mimeData(baseIndexes);
175 if (!data)
176 return;
178 // use screen width and itemDelegate()->sizeHint() to determine size of the pixmap
179 int screenWidth = QApplication::desktop()->screenGeometry(this).width();
180 int maxWidth = qMax(400, screenWidth / 4);
181 QSize size(maxWidth, 0);
183 // Show a "+ X more items" text after so many entries
184 const int maxItems = 20;
186 QStyleOptionViewItem opt;
187 opt.initFrom(this);
188 opt.rect.setWidth(maxWidth);
189 opt.rect.setHeight(itemDelegate()->sizeHint(opt, baseIndexes.at(0)).height());
190 size.setHeight(qMin(maxItems + 1, baseIndexes.size()) * opt.rect.height());
191 // State_Selected provides for nice background of the items
192 opt.state |= QStyle::State_Selected;
194 // paint list of selected items using itemDelegate() to be consistent with style
195 QPixmap pixmap(size);
196 pixmap.fill(Qt::transparent);
197 QPainter p(&pixmap);
199 for (int i = 0; i < baseIndexes.size(); ++i) {
200 opt.rect.moveTop(i * opt.rect.height());
201 if (i == maxItems) {
202 p.fillRect(opt.rect, palette().color(QPalette::Disabled, QPalette::Highlight));
203 p.setBrush(palette().color(QPalette::Disabled, QPalette::HighlightedText));
204 p.drawText(opt.rect, Qt::AlignRight, tr("+ %n additional item(s)", 0, baseIndexes.size() - maxItems));
205 break;
207 itemDelegate()->paint(&p, opt, baseIndexes.at(i));
210 QDrag *drag = new QDrag(this);
211 drag->setPixmap(pixmap);
212 drag->setMimeData(data);
213 drag->setHotSpot(QPoint(0, 0));
215 Qt::DropAction dropAction = Qt::IgnoreAction;
216 if (defaultDropAction() != Qt::IgnoreAction && (supportedActions & defaultDropAction()))
217 dropAction = defaultDropAction();
218 else if (supportedActions & Qt::CopyAction && dragDropMode() != QAbstractItemView::InternalMove)
219 dropAction = Qt::CopyAction;
220 if (drag->exec(supportedActions, dropAction) == Qt::MoveAction) {
221 // QAbstractItemView::startDrag calls d->clearOrRemove() here, so
222 // this is a copy of QAbstractItemModelPrivate::clearOrRemove();
223 const QItemSelection selection = selectionModel()->selection();
224 QList<QItemSelectionRange>::const_iterator it = selection.constBegin();
226 if (!dragDropOverwriteMode()) {
227 for (; it != selection.constEnd(); ++it) {
228 QModelIndex parent = it->parent();
229 if (it->left() != 0)
230 continue;
231 if (it->right() != (model()->columnCount(parent) - 1))
232 continue;
233 int count = it->bottom() - it->top() + 1;
234 model()->removeRows(it->top(), count, parent);
236 } else {
237 // we can't remove the rows so reset the items (i.e. the view is like a table)
238 QModelIndexList list = selection.indexes();
239 for (int i = 0; i < list.size(); ++i) {
240 QModelIndex index = list.at(i);
241 QMap<int, QVariant> roles = model()->itemData(index);
242 for (QMap<int, QVariant>::Iterator it = roles.begin(); it != roles.end(); ++it)
243 it.value() = QVariant();
244 model()->setItemData(index, roles);
251 QModelIndexList MsgListView::selectedTree() const
253 QModelIndexList indexes;
254 QModelIndexList selected = selectedIndexes();
255 const int originalItems = selected.length(); // only check collapsed/expanded status on original selection
256 for (int i = 0; i < selected.length(); ++i) {
257 const QModelIndex item = selected[i];
258 if (item.column() != 0 || !item.data(Imap::Mailbox::RoleMessageUid).isValid())
259 continue;
260 indexes << item;
261 // Now see if this is a collapsed thread and include all the collapsed items as needed
262 // Also note that this is recursive - each child found is run through this same item loop for validity/child checks as well
263 if (i >= originalItems || !isExpanded(item)) {
264 for (int j = 0; j < item.model()->rowCount(item); ++j) {
265 selected << item.child(j, 0); // Make sure this is run through the main loop as well - don't add it directly
269 return indexes;
272 void MsgListView::slotFixSize()
274 if (!m_autoResizeSections)
275 return;
277 if (header()->visualIndex(Imap::Mailbox::MsgListModel::SUBJECT) == -1) {
278 // calling setResizeMode() would assert()
279 return;
282 header()->setStretchLastSection(false);
283 for (int i = 0; i < Imap::Mailbox::MsgListModel::COLUMN_COUNT; ++i) {
284 QHeaderView::ResizeMode resizeMode = resizeModeForColumn(i);
285 header()->setSectionResizeMode(i, resizeMode);
286 setColumnWidth(i, sizeHintForColumn(i));
290 QHeaderView::ResizeMode MsgListView::resizeModeForColumn(const int column) const
292 switch (column) {
293 case Imap::Mailbox::MsgListModel::SUBJECT:
294 return QHeaderView::Stretch;
295 case Imap::Mailbox::MsgListModel::SEEN:
296 case Imap::Mailbox::MsgListModel::FLAGGED:
297 case Imap::Mailbox::MsgListModel::ATTACHMENT:
298 return QHeaderView::Fixed;
299 default:
300 return QHeaderView::Interactive;
304 void MsgListView::slotExpandWholeSubtree(const QModelIndex &rootIndex)
306 if (rootIndex.parent().isValid())
307 return;
309 QVector<QModelIndex> queue(1, rootIndex);
310 for (int i = 0; i < queue.size(); ++i) {
311 const QModelIndex currentIndex = queue[i];
312 // Append all children to the queue...
313 for (int j = 0; j < currentIndex.model()->rowCount(currentIndex); ++j)
314 queue.append(currentIndex.child(j, 0));
315 // ...and expand the current index
316 if (currentIndex.model()->hasChildren(currentIndex))
317 expand(currentIndex);
321 void MsgListView::slotUpdateHeaderActions()
323 Q_ASSERT(header());
324 // At first, remove all actions
325 QList<QAction *> actions = header()->actions();
326 Q_FOREACH(QAction *action, actions) {
327 header()->removeAction(action);
328 headerFieldsMapper->removeMappings(action);
329 action->deleteLater();
331 actions.clear();
332 // Now add them again
333 for (int i = 0; i < header()->count(); ++i) {
334 QString message = header()->model() ? header()->model()->headerData(i, Qt::Horizontal).toString() : QString::number(i);
335 QAction *action = new QAction(message, this);
336 action->setCheckable(true);
337 action->setChecked(true);
338 connect(action, &QAction::toggled, headerFieldsMapper, static_cast<void (QSignalMapper::*)()>(&QSignalMapper::map));
339 headerFieldsMapper->setMapping(action, i);
340 header()->addAction(action);
342 // Next, add some special handling of certain columns
343 switch (i) {
344 case Imap::Mailbox::MsgListModel::SEEN:
345 // This column doesn't have a textual description
346 action->setText(tr("Seen status"));
347 break;
348 case Imap::Mailbox::MsgListModel::FLAGGED:
349 action->setText(tr("Flagged status"));
350 break;
351 case Imap::Mailbox::MsgListModel::ATTACHMENT:
352 action->setText(tr("Attachment"));
353 break;
354 case Imap::Mailbox::MsgListModel::TO:
355 case Imap::Mailbox::MsgListModel::CC:
356 case Imap::Mailbox::MsgListModel::BCC:
357 case Imap::Mailbox::MsgListModel::RECEIVED_DATE:
358 // And these should be hidden by default
359 action->toggle();
360 break;
361 default:
362 break;
366 // Make sure to kick the header again so that it shows reasonable sizing
367 slotFixSize();
370 /** @short Handle columns added to MsgListModel and set their default properties
372 * When a new version of the underlying model got a new column, the old saved state of the GUI might only contain data for the old columns.
373 * Therefore it is important to explicitly restore the default for new columns, if any.
375 void MsgListView::slotHandleNewColumns(int oldCount, int newCount)
377 for (int i = oldCount; i < newCount; ++i) {
378 switch(i) {
379 case Imap::Mailbox::MsgListModel::FLAGGED:
380 header()->moveSection(i,0);
381 break;
382 case Imap::Mailbox::MsgListModel::ATTACHMENT:
383 header()->moveSection(i,0);
384 break;
387 setColumnWidth(i, sizeHintForColumn(i));
391 void MsgListView::slotHeaderSectionVisibilityToggled(int section)
393 QList<QAction *> actions = header()->actions();
394 if (section >= actions.size() || section < 0)
395 return;
396 bool hide = ! actions[section]->isChecked();
398 if (hide && header()->hiddenSectionCount() == header()->count() - 1) {
399 // This would hide the very last section, which would hide the whole header view
400 actions[section]->setChecked(true);
401 } else {
402 header()->setSectionHidden(section, hide);
406 void MsgListView::updateActionsAfterRestoredState()
408 m_autoResizeSections = false;
409 QList<QAction *> actions = header()->actions();
410 for (int i = 0; i < actions.size(); ++i) {
411 actions[i]->setChecked(!header()->isSectionHidden(i));
415 /** @short Overridden from QTreeView::setModel
417 The whole point is that we have to listen for sortingPreferenceChanged to update your header view when sorting is requested
418 but cannot be fulfilled.
420 void MsgListView::setModel(QAbstractItemModel *model)
422 if (this->model()) {
423 if (Imap::Mailbox::PrettyMsgListModel *prettyModel = findPrettyMsgListModel(this->model())) {
424 disconnect(prettyModel, &Imap::Mailbox::PrettyMsgListModel::sortingPreferenceChanged,
425 this, &MsgListView::slotHandleSortCriteriaChanged);
426 disconnect(qobject_cast<Imap::Mailbox::ThreadingMsgListModel*>(prettyModel->sourceModel())->sourceModel(),
427 &QAbstractItemModel::rowsAboutToBeRemoved,
428 this, &MsgListView::slotMsgListModelRowsAboutToBeRemoved);
431 QTreeView::setModel(model);
432 if (Imap::Mailbox::PrettyMsgListModel *prettyModel = findPrettyMsgListModel(model)) {
433 connect(prettyModel, &Imap::Mailbox::PrettyMsgListModel::sortingPreferenceChanged,
434 this, &MsgListView::slotHandleSortCriteriaChanged);
435 connect(qobject_cast<Imap::Mailbox::ThreadingMsgListModel*>(prettyModel->sourceModel())->sourceModel(),
436 &QAbstractItemModel::rowsAboutToBeRemoved,
437 this, &MsgListView::slotMsgListModelRowsAboutToBeRemoved);
441 /** @short Get ThreadingMsgListModel index and call the next handler */
442 void MsgListView::slotMsgListModelRowsAboutToBeRemoved(const QModelIndex &parent, int start, int end)
444 Q_ASSERT(!parent.isValid());
446 auto threadingModel = qobject_cast<Imap::Mailbox::ThreadingMsgListModel*>(findPrettyMsgListModel(model())->sourceModel());
447 for (int i = start; i <= end; ++i) {
448 QModelIndex index = threadingModel->sourceModel()->index(i, 0, parent);
449 Q_ASSERT(index.isValid());
450 QModelIndex translated = threadingModel->mapFromSource(index);
452 if (translated.isValid())
453 slotThreadingMsgListModelRowAboutToBeRemoved(translated);
457 /** @short Keep the cursor in place for better keyboard usability
459 In the worst case this is an O(log n). Such a worst case is when messages are removed in descending
460 order starting from last one of the view. But in practice, there are many cases when it performs
461 well better.
463 A performance-wise approach could be to hook signal layoutAboutToBeChanged, but the underlying model
464 has removed the rows by then and it makes everything complicated.
466 void MsgListView::slotThreadingMsgListModelRowAboutToBeRemoved(const QModelIndex &index)
468 Imap::Mailbox::PrettyMsgListModel *prettyModel = findPrettyMsgListModel(model());
469 Q_ASSERT(!index.isValid() || index.model() == qobject_cast<Imap::Mailbox::ThreadingMsgListModel*>(prettyModel->sourceModel()));
470 QModelIndex current = currentIndex();
471 if (current.isValid() && prettyModel->mapFromSource(index) == current) {
472 setCurrentIndexToNextValid(current);
476 /** @short Try to move the cursor to next message
478 Used when the current message disappearing.
480 void MsgListView::setCurrentIndexToNextValid(const QModelIndex &current)
482 Q_ASSERT(current.isValid());
483 Imap::Mailbox::PrettyMsgListModel *prettyModel = findPrettyMsgListModel(model());
484 Q_ASSERT(current.model() == prettyModel);
485 for (bool forward : {true,false}) {
486 QModelIndex walker = forward ? indexBelow(current) : indexAbove(current);
487 while (walker.isValid()) {
488 // Queued for pruning..?
489 if (prettyModel->data(walker, Imap::Mailbox::RoleMessageUid).isValid()) {
490 // Do not activate, just keep the cursor in place.
491 selectionModel()->setCurrentIndex(walker, QItemSelectionModel::NoUpdate);
492 // It has won. For now.
493 return;
495 walker = forward ? indexBelow(walker) : indexAbove(walker);
500 void MsgListView::slotHandleSortCriteriaChanged(int column, Qt::SortOrder order)
502 // The if-clause is needed to prevent infinite recursion
503 if (header()->sortIndicatorSection() != column || header()->sortIndicatorOrder() != order) {
504 header()->setSortIndicator(column, order);
508 /** @short Walk the hierarchy of proxy models up until we stop at the PrettyMsgListModel or the first non-proxy model */
509 Imap::Mailbox::PrettyMsgListModel *MsgListView::findPrettyMsgListModel(QAbstractItemModel *model)
511 while (QAbstractProxyModel *proxy = qobject_cast<QAbstractProxyModel*>(model)) {
512 Imap::Mailbox::PrettyMsgListModel *prettyModel = qobject_cast<Imap::Mailbox::PrettyMsgListModel*>(proxy);
513 if (prettyModel)
514 return prettyModel;
515 else
516 model = proxy->sourceModel();
518 return 0;
521 void MsgListView::setAutoActivateAfterKeyNavigation(bool enabled)
523 m_autoActivateAfterKeyNavigation = enabled;