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"
25 #include <QApplication>
26 #include <QDesktopWidget>
28 #include <QFontMetrics>
29 #include <QHeaderView>
32 #include <QSignalMapper>
34 #include "MsgItemDelegate.h"
35 #include "Imap/Model/MsgListModel.h"
36 #include "Imap/Model/PrettyMsgListModel.h"
37 #include "Imap/Model/ThreadingMsgListModel.h"
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
);
56 setRootIsDecorated(false);
57 // Some subthreads might be huuuuuuuuuuge, so prevent indenting them too heavily
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
);
128 case Imap::Mailbox::MsgListModel::SEEN
:
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();
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
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
))
169 if (index
.column() == Imap::Mailbox::MsgListModel::SUBJECT
)
170 baseIndexes
<< index
;
173 if (!baseIndexes
.isEmpty()) {
174 QMimeData
*data
= model()->mimeData(baseIndexes
);
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
;
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
);
199 for (int i
= 0; i
< baseIndexes
.size(); ++i
) {
200 opt
.rect
.moveTop(i
* opt
.rect
.height());
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
));
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();
231 if (it
->right() != (model()->columnCount(parent
) - 1))
233 int count
= it
->bottom() - it
->top() + 1;
234 model()->removeRows(it
->top(), count
, parent
);
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())
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
272 void MsgListView::slotFixSize()
274 if (!m_autoResizeSections
)
277 if (header()->visualIndex(Imap::Mailbox::MsgListModel::SUBJECT
) == -1) {
278 // calling setResizeMode() would assert()
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
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
;
300 return QHeaderView::Interactive
;
304 void MsgListView::slotExpandWholeSubtree(const QModelIndex
&rootIndex
)
306 if (rootIndex
.parent().isValid())
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()
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();
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
344 case Imap::Mailbox::MsgListModel::SEEN
:
345 // This column doesn't have a textual description
346 action
->setText(tr("Seen status"));
348 case Imap::Mailbox::MsgListModel::FLAGGED
:
349 action
->setText(tr("Flagged status"));
351 case Imap::Mailbox::MsgListModel::ATTACHMENT
:
352 action
->setText(tr("Attachment"));
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
366 // Make sure to kick the header again so that it shows reasonable sizing
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
) {
379 case Imap::Mailbox::MsgListModel::FLAGGED
:
380 header()->moveSection(i
,0);
382 case Imap::Mailbox::MsgListModel::ATTACHMENT
:
383 header()->moveSection(i
,0);
387 setColumnWidth(i
, sizeHintForColumn(i
));
391 void MsgListView::slotHeaderSectionVisibilityToggled(int section
)
393 QList
<QAction
*> actions
= header()->actions();
394 if (section
>= actions
.size() || section
< 0)
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);
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
)
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
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
¤t
)
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.
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
);
516 model
= proxy
->sourceModel();
521 void MsgListView::setAutoActivateAfterKeyNavigation(bool enabled
)
523 m_autoActivateAfterKeyNavigation
= enabled
;