1 /* SPDX-FileCopyrightText: 2003-2020 The KPhotoAlbum Development Team
2 
3    SPDX-License-Identifier: GPL-2.0-or-later
4 */
5 #include "ThumbnailModel.h"
6 
7 #include "CellGeometry.h"
8 #include "FilterWidget.h"
9 #include "Logging.h"
10 #include "SelectionMaintainer.h"
11 #include "ThumbnailRequest.h"
12 #include "ThumbnailWidget.h"
13 
14 #include <DB/ImageDB.h>
15 #include <ImageManager/AsyncLoader.h>
16 #include <Utilities/FileUtil.h>
17 #include <kpabase/FileName.h>
18 #include <kpabase/Logging.h>
19 #include <kpabase/SettingsData.h>
20 #include <kpathumbnails/ThumbnailCache.h>
21 
22 #include <KLocalizedString>
23 #include <QElapsedTimer>
24 #include <QIcon>
25 #include <QLoggingCategory>
26 
ThumbnailModel(ThumbnailFactory * factory,const ImageManager::ThumbnailCache * thumbnailCache)27 ThumbnailView::ThumbnailModel::ThumbnailModel(ThumbnailFactory *factory, const ImageManager::ThumbnailCache *thumbnailCache)
28     : ThumbnailComponent(factory)
29     , m_sortDirection(Settings::SettingsData::instance()->showNewestThumbnailFirst() ? NewestFirst : OldestFirst)
30     , m_firstVisibleRow(-1)
31     , m_lastVisibleRow(-1)
32     , m_thumbnailCache(thumbnailCache)
33 {
34     connect(DB::ImageDB::instance(), SIGNAL(imagesDeleted(DB::FileNameList)), this, SLOT(imagesDeletedFromDB(DB::FileNameList)));
35     m_ImagePlaceholder = QIcon::fromTheme(QLatin1String("image-x-generic")).pixmap(cellGeometryInfo()->preferredIconSize());
36     m_VideoPlaceholder = QIcon::fromTheme(QLatin1String("video-x-generic")).pixmap(cellGeometryInfo()->preferredIconSize());
37 
38     m_filter.setSearchMode(0);
39     connect(this, &ThumbnailModel::filterChanged, this, &ThumbnailModel::updateDisplayModel);
40 }
41 
stackOrderComparator(const DB::FileName & a,const DB::FileName & b)42 static bool stackOrderComparator(const DB::FileName &a, const DB::FileName &b)
43 {
44     return DB::ImageDB::instance()->info(a)->stackOrder() < DB::ImageDB::instance()->info(b)->stackOrder();
45 }
46 
updateDisplayModel()47 void ThumbnailView::ThumbnailModel::updateDisplayModel()
48 {
49     QElapsedTimer timer;
50     timer.start();
51     beginResetModel();
52     ImageManager::AsyncLoader::instance()->stop(model(), ImageManager::StopOnlyNonPriorityLoads);
53 
54     // Note, this can be simplified, if we make the database backend already
55     // return things in the right order. Then we only need one pass while now
56     // we need to go through the list two times.
57 
58     /* Extract all stacks we have first. Different stackid's might be
59      * intermingled in the result so we need to know this ahead before
60      * creating the display list.
61      */
62     typedef QList<DB::FileName> StackList;
63     typedef QMap<DB::StackID, StackList> StackMap;
64     StackMap stackContents;
65     for (const DB::FileName &fileName : qAsConst(m_imageList)) {
66         const DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
67         if (imageInfo && imageInfo->isStacked()) {
68             DB::StackID stackid = imageInfo->stackId();
69             stackContents[stackid].append(fileName);
70         }
71     }
72 
73     /*
74      * All stacks need to be ordered in their stack order. We don't rely that
75      * the images actually came in the order necessary.
76      */
77     for (StackMap::iterator it = stackContents.begin(); it != stackContents.end(); ++it) {
78         std::stable_sort(it->begin(), it->end(), stackOrderComparator);
79     }
80 
81     /* Build the final list to be displayed. That is basically the sequence
82      * we got from the original, but the stacks shown with all images together
83      * in the right sequence or collapsed showing only the top image.
84      */
85     m_displayList = DB::FileNameList();
86     QSet<DB::StackID> alreadyShownStacks;
87     for (const DB::FileName &fileName : qAsConst(m_imageList)) {
88         const DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
89         if (!m_filter.match(imageInfo))
90             continue;
91         if (imageInfo && imageInfo->isStacked()) {
92             DB::StackID stackid = imageInfo->stackId();
93             if (alreadyShownStacks.contains(stackid))
94                 continue;
95             StackMap::iterator found = stackContents.find(stackid);
96             Q_ASSERT(found != stackContents.end());
97             const StackList &orderedStack = *found;
98             if (m_expandedStacks.contains(stackid)) {
99                 for (const DB::FileName &fileName : orderedStack) {
100                     m_displayList.append(fileName);
101                 }
102             } else {
103                 m_displayList.append(orderedStack.at(0));
104             }
105             alreadyShownStacks.insert(stackid);
106         } else {
107             m_displayList.append(fileName);
108         }
109     }
110 
111     if (m_sortDirection != OldestFirst)
112         m_displayList = m_displayList.reversed();
113 
114     updateIndexCache();
115 
116     emit collapseAllStacksEnabled(m_expandedStacks.size() > 0);
117     emit expandAllStacksEnabled(m_allStacks.size() != model()->m_expandedStacks.size());
118     endResetModel();
119     qCInfo(TimingLog) << "ThumbnailModel::updateDisplayModel(): " << timer.restart() << "ms.";
120 }
121 
toggleStackExpansion(const DB::FileName & fileName)122 void ThumbnailView::ThumbnailModel::toggleStackExpansion(const DB::FileName &fileName)
123 {
124     const DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
125     if (imageInfo) {
126         DB::StackID stackid = imageInfo->stackId();
127         model()->beginResetModel();
128         if (m_expandedStacks.contains(stackid))
129             m_expandedStacks.remove(stackid);
130         else
131             m_expandedStacks.insert(stackid);
132         updateDisplayModel();
133         model()->endResetModel();
134     }
135 }
136 
collapseAllStacks()137 void ThumbnailView::ThumbnailModel::collapseAllStacks()
138 {
139     m_expandedStacks.clear();
140     updateDisplayModel();
141 }
142 
expandAllStacks()143 void ThumbnailView::ThumbnailModel::expandAllStacks()
144 {
145     m_expandedStacks = m_allStacks;
146     updateDisplayModel();
147 }
148 
setImageList(const DB::FileNameList & items)149 void ThumbnailView::ThumbnailModel::setImageList(const DB::FileNameList &items)
150 {
151     m_imageList = items;
152     m_allStacks.clear();
153     for (const DB::FileName &fileName : items) {
154         const DB::ImageInfoPtr info = DB::ImageDB::instance()->info(fileName);
155         if (info && info->isStacked())
156             m_allStacks << info->stackId();
157     }
158     updateDisplayModel();
159     preloadThumbnails();
160 }
161 
imageList(Order order) const162 DB::FileNameList ThumbnailView::ThumbnailModel::imageList(Order order) const
163 {
164     if (order == SortedOrder && m_sortDirection == NewestFirst)
165         return m_displayList.reversed();
166     else
167         return m_displayList;
168 }
169 
imagesDeletedFromDB(const DB::FileNameList & list)170 void ThumbnailView::ThumbnailModel::imagesDeletedFromDB(const DB::FileNameList &list)
171 {
172     SelectionMaintainer dummy(widget(), model());
173 
174     for (const DB::FileName &fileName : list) {
175         m_displayList.removeAll(fileName);
176         m_imageList.removeAll(fileName);
177     }
178     updateDisplayModel();
179 }
180 
indexOf(const DB::FileName & fileName)181 int ThumbnailView::ThumbnailModel::indexOf(const DB::FileName &fileName)
182 {
183     Q_ASSERT(!fileName.isNull());
184     if (!m_fileNameToIndex.contains(fileName))
185         m_fileNameToIndex.insert(fileName, m_displayList.indexOf(fileName));
186 
187     return m_fileNameToIndex[fileName];
188 }
189 
indexOf(const DB::FileName & fileName) const190 int ThumbnailView::ThumbnailModel::indexOf(const DB::FileName &fileName) const
191 {
192     Q_ASSERT(!fileName.isNull());
193     if (!m_fileNameToIndex.contains(fileName))
194         return -1;
195 
196     return m_fileNameToIndex[fileName];
197 }
198 
updateIndexCache()199 void ThumbnailView::ThumbnailModel::updateIndexCache()
200 {
201     m_fileNameToIndex.clear();
202     int index = 0;
203     for (const DB::FileName &fileName : qAsConst(m_displayList)) {
204         m_fileNameToIndex[fileName] = index;
205         ++index;
206     }
207 }
208 
rightDropItem() const209 DB::FileName ThumbnailView::ThumbnailModel::rightDropItem() const
210 {
211     return m_rightDrop;
212 }
213 
setRightDropItem(const DB::FileName & item)214 void ThumbnailView::ThumbnailModel::setRightDropItem(const DB::FileName &item)
215 {
216     m_rightDrop = item;
217 }
218 
leftDropItem() const219 DB::FileName ThumbnailView::ThumbnailModel::leftDropItem() const
220 {
221     return m_leftDrop;
222 }
223 
setLeftDropItem(const DB::FileName & item)224 void ThumbnailView::ThumbnailModel::setLeftDropItem(const DB::FileName &item)
225 {
226     m_leftDrop = item;
227 }
228 
setSortDirection(SortDirection direction)229 void ThumbnailView::ThumbnailModel::setSortDirection(SortDirection direction)
230 {
231     if (direction == m_sortDirection)
232         return;
233 
234     Settings::SettingsData::instance()->setShowNewestFirst(direction == NewestFirst);
235     m_displayList = m_displayList.reversed();
236     updateIndexCache();
237 
238     m_sortDirection = direction;
239 }
240 
isItemInExpandedStack(const DB::StackID & id) const241 bool ThumbnailView::ThumbnailModel::isItemInExpandedStack(const DB::StackID &id) const
242 {
243     return m_expandedStacks.contains(id);
244 }
245 
imageCount() const246 int ThumbnailView::ThumbnailModel::imageCount() const
247 {
248     return m_displayList.size();
249 }
250 
setOverrideImage(const DB::FileName & fileName,const QPixmap & pixmap)251 void ThumbnailView::ThumbnailModel::setOverrideImage(const DB::FileName &fileName, const QPixmap &pixmap)
252 {
253     if (pixmap.isNull())
254         m_overrideFileName = DB::FileName();
255     else {
256         m_overrideFileName = fileName;
257         m_overrideImage = pixmap;
258     }
259     emit dataChanged(fileNameToIndex(fileName), fileNameToIndex(fileName));
260 }
261 
imageAt(int index) const262 DB::FileName ThumbnailView::ThumbnailModel::imageAt(int index) const
263 {
264     Q_ASSERT(index >= 0 && index < imageCount());
265     return m_displayList.at(index);
266 }
267 
rowCount(const QModelIndex &) const268 int ThumbnailView::ThumbnailModel::rowCount(const QModelIndex &) const
269 {
270     return imageCount();
271 }
272 
data(const QModelIndex & index,int role) const273 QVariant ThumbnailView::ThumbnailModel::data(const QModelIndex &index, int role) const
274 {
275     if (!index.isValid() || index.row() >= m_displayList.size())
276         return QVariant();
277 
278     if (role == Qt::DecorationRole) {
279         const DB::FileName fileName = m_displayList.at(index.row());
280         return pixmap(fileName);
281     }
282 
283     if (role == Qt::DisplayRole)
284         return thumbnailText(index);
285 
286     return QVariant();
287 }
288 
requestThumbnail(const DB::FileName & fileName,const ImageManager::Priority priority)289 void ThumbnailView::ThumbnailModel::requestThumbnail(const DB::FileName &fileName, const ImageManager::Priority priority)
290 {
291     const DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
292     if (!imageInfo)
293         return;
294     // request the thumbnail in the size that is set in the settings, not in the current grid size:
295     const QSize cellSize = cellGeometryInfo()->baseIconSize();
296     const int angle = imageInfo->angle();
297     const int row = indexOf(fileName);
298     ThumbnailRequest *request
299         = new ThumbnailRequest(row, fileName, cellSize, angle, this);
300     request->setPriority(priority);
301     ImageManager::AsyncLoader::instance()->load(request);
302 }
303 
pixmapLoaded(ImageManager::ImageRequest * request,const QImage &)304 void ThumbnailView::ThumbnailModel::pixmapLoaded(ImageManager::ImageRequest *request, const QImage & /*image*/)
305 {
306     const DB::FileName fileName = request->databaseFileName();
307     const QSize fullSize = request->fullSize();
308 
309     // As a result of the image being loaded, we emit the dataChanged signal, which in turn asks the delegate to paint the cell
310     // The delegate now fetches the newly loaded image from the cache.
311 
312     DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
313     // (hzeller): figure out, why the size is set here. We do an implicit
314     // write here to the database.
315     // (jzarl 2020-07-25): when loading a fullsize pixmap, we get the size "for free";
316     // calculating it separately would cost us more than writing to the database from here.
317     if (fullSize.isValid() && imageInfo) {
318         imageInfo->setSize(fullSize);
319     }
320 
321     emit dataChanged(fileNameToIndex(fileName), fileNameToIndex(fileName));
322 }
323 
thumbnailText(const QModelIndex & index) const324 QString ThumbnailView::ThumbnailModel::thumbnailText(const QModelIndex &index) const
325 {
326     const DB::FileName fileName = imageAt(index.row());
327     const auto info = DB::ImageDB::instance()->info(fileName);
328 
329     QString text;
330 
331     const QSize cellSize = cellGeometryInfo()->preferredIconSize();
332     const int thumbnailHeight = cellSize.height() - 2 * Settings::SettingsData::instance()->thumbnailSpace();
333     const int thumbnailWidth = cellSize.width(); // no subtracting here
334     const int maxCharacters = thumbnailHeight / QFontMetrics(widget()->font()).maxWidth() * 2;
335 
336     if (Settings::SettingsData::instance()->displayLabels()) {
337         QString line = info->label();
338         if (stringWidth(line) > thumbnailWidth) {
339             line = line.left(maxCharacters);
340             line += QLatin1String(" ...");
341         }
342         text += line + QLatin1String("\n");
343     }
344 
345     if (Settings::SettingsData::instance()->displayCategories()) {
346         QStringList grps = info->availableCategories();
347         for (QStringList::const_iterator it = grps.constBegin(); it != grps.constEnd(); ++it) {
348             QString category = *it;
349             if (category != i18n("Folder") && category != i18n("Media Type")) {
350                 Utilities::StringSet items = info->itemsOfCategory(category);
351 
352                 if (DB::ImageDB::instance()->untaggedCategoryFeatureConfigured()
353                     && !Settings::SettingsData::instance()->untaggedImagesTagVisible()) {
354 
355                     if (category == Settings::SettingsData::instance()->untaggedCategory()) {
356                         if (items.contains(Settings::SettingsData::instance()->untaggedTag())) {
357                             items.remove(Settings::SettingsData::instance()->untaggedTag());
358                         }
359                     }
360                 }
361 
362                 if (!items.empty()) {
363                     QString line;
364                     bool first = true;
365                     for (Utilities::StringSet::const_iterator it2 = items.begin(); it2 != items.end(); ++it2) {
366                         QString item = *it2;
367                         if (first)
368                             first = false;
369                         else
370                             line += QLatin1String(", ");
371                         line += item;
372                     }
373                     if (stringWidth(line) > thumbnailWidth) {
374                         line = line.left(maxCharacters);
375                         line += QLatin1String(" ...");
376                     }
377                     text += line + QLatin1String("\n");
378                 }
379             }
380         }
381     }
382 
383     return text.trimmed();
384 }
385 
updateCell(int row)386 void ThumbnailView::ThumbnailModel::updateCell(int row)
387 {
388     updateCell(index(row, 0));
389 }
390 
updateCell(const QModelIndex & index)391 void ThumbnailView::ThumbnailModel::updateCell(const QModelIndex &index)
392 {
393     emit dataChanged(index, index);
394 }
395 
updateCell(const DB::FileName & fileName)396 void ThumbnailView::ThumbnailModel::updateCell(const DB::FileName &fileName)
397 {
398     if (fileName.isNull())
399         return;
400     updateCell(indexOf(fileName));
401 }
402 
fileNameToIndex(const DB::FileName & fileName) const403 QModelIndex ThumbnailView::ThumbnailModel::fileNameToIndex(const DB::FileName &fileName) const
404 {
405     if (fileName.isNull())
406         return QModelIndex();
407     else
408         return index(indexOf(fileName), 0);
409 }
410 
pixmap(const DB::FileName & fileName) const411 QPixmap ThumbnailView::ThumbnailModel::pixmap(const DB::FileName &fileName) const
412 {
413     if (m_overrideFileName == fileName)
414         return m_overrideImage;
415 
416     const DB::ImageInfoPtr imageInfo = DB::ImageDB::instance()->info(fileName);
417     if (imageInfo == DB::ImageInfoPtr(nullptr))
418         return QPixmap();
419 
420     if (m_thumbnailCache->contains(fileName)) {
421         // the cached thumbnail needs to be scaled to the actual thumbnail size:
422         return m_thumbnailCache->lookup(fileName).scaled(cellGeometryInfo()->preferredIconSize(), Qt::KeepAspectRatio);
423     }
424 
425     const_cast<ThumbnailView::ThumbnailModel *>(this)->requestThumbnail(fileName, ImageManager::ThumbnailVisible);
426     if (imageInfo->isVideo())
427         return m_VideoPlaceholder;
428     else
429         return m_ImagePlaceholder;
430 }
431 
isFiltered() const432 bool ThumbnailView::ThumbnailModel::isFiltered() const
433 {
434     return !m_filter.isNull();
435 }
436 
createFilterWidget(QWidget * parent)437 ThumbnailView::FilterWidget *ThumbnailView::ThumbnailModel::createFilterWidget(QWidget *parent)
438 {
439 
440     auto filterWidget = new FilterWidget(parent);
441     connect(this, &ThumbnailModel::filterChanged, filterWidget, &FilterWidget::setFilter);
442     connect(filterWidget, &FilterWidget::ratingChanged, this, &ThumbnailModel::filterByRating);
443     connect(filterWidget, &FilterWidget::filterToggled, this, &ThumbnailModel::toggleFilter);
444     return filterWidget;
445 }
446 
thumbnailStillNeeded(int row) const447 bool ThumbnailView::ThumbnailModel::thumbnailStillNeeded(int row) const
448 {
449     return (row >= m_firstVisibleRow && row <= m_lastVisibleRow);
450 }
451 
updateVisibleRowInfo()452 void ThumbnailView::ThumbnailModel::updateVisibleRowInfo()
453 {
454     m_firstVisibleRow = widget()->indexAt(QPoint(0, 0)).row();
455     const int columns = widget()->width() / cellGeometryInfo()->cellSize().width();
456     const int rows = widget()->height() / cellGeometryInfo()->cellSize().height();
457     m_lastVisibleRow = qMin(m_firstVisibleRow + columns * (rows + 1), rowCount(QModelIndex()));
458 
459     // the cellGeometry has changed -> update placeholders
460     m_ImagePlaceholder = QIcon::fromTheme(QLatin1String("image-x-generic")).pixmap(cellGeometryInfo()->preferredIconSize());
461     m_VideoPlaceholder = QIcon::fromTheme(QLatin1String("video-x-generic")).pixmap(cellGeometryInfo()->preferredIconSize());
462 }
463 
toggleFilter(bool enable)464 void ThumbnailView::ThumbnailModel::toggleFilter(bool enable)
465 {
466     if (!enable)
467         clearFilter();
468     else if (m_filter.isNull()) {
469         std::swap(m_filter, m_previousFilter);
470         emit filterChanged(m_filter);
471     }
472 }
473 
clearFilter()474 void ThumbnailView::ThumbnailModel::clearFilter()
475 {
476     if (!m_filter.isNull()) {
477         qCDebug(ThumbnailViewLog) << "Filter cleared.";
478         m_previousFilter = m_filter;
479         m_filter = DB::ImageSearchInfo();
480         emit filterChanged(m_filter);
481     }
482 }
483 
filterByRating(short rating)484 void ThumbnailView::ThumbnailModel::filterByRating(short rating)
485 {
486     Q_ASSERT(-1 <= rating && rating <= 10);
487     qCDebug(ThumbnailViewLog) << "Filter set: rating(" << rating << ")";
488     m_filter.setRating(rating);
489     emit filterChanged(m_filter);
490 }
491 
toggleRatingFilter(short rating)492 void ThumbnailView::ThumbnailModel::toggleRatingFilter(short rating)
493 {
494     if (m_filter.rating() == rating) {
495         filterByRating(rating);
496     } else {
497         filterByRating(-1);
498         qCDebug(ThumbnailViewLog) << "Filter removed: rating";
499         m_filter.setRating(-1);
500         m_filter.checkIfNull();
501         emit filterChanged(m_filter);
502     }
503 }
504 
filterByCategory(const QString & category,const QString & tag)505 void ThumbnailView::ThumbnailModel::filterByCategory(const QString &category, const QString &tag)
506 {
507     qCDebug(ThumbnailViewLog) << "Filter added: category(" << category << "," << tag << ")";
508 
509     m_filter.addAnd(category, tag);
510     emit filterChanged(m_filter);
511 }
512 
toggleCategoryFilter(const QString & category,const QString & tag)513 void ThumbnailView::ThumbnailModel::toggleCategoryFilter(const QString &category, const QString &tag)
514 {
515     auto tags = m_filter.categoryMatchText(category).split(QLatin1String("&"), QString::SkipEmptyParts);
516     for (const auto &existingTag : tags) {
517         if (tag == existingTag.trimmed()) {
518             qCDebug(ThumbnailViewLog) << "Filter removed: category(" << category << "," << tag << ")";
519             tags.removeAll(existingTag);
520             m_filter.setCategoryMatchText(category, tags.join(QLatin1String(" & ")));
521             m_filter.checkIfNull();
522             emit filterChanged(m_filter);
523             return;
524         }
525     }
526     filterByCategory(category, tag);
527 }
528 
filterByFreeformText(const QString & text)529 void ThumbnailView::ThumbnailModel::filterByFreeformText(const QString &text)
530 {
531     qCDebug(ThumbnailViewLog) << "Filter added: freeform_match(" << text << ")";
532     m_filter.setFreeformMatchText(text);
533     emit filterChanged(m_filter);
534 }
535 
preloadThumbnails()536 void ThumbnailView::ThumbnailModel::preloadThumbnails()
537 {
538     // FIXME: it would make a lot of sense to merge preloadThumbnails() with pixmap()
539     // and maybe also move the caching stuff into the ImageManager
540     for (const DB::FileName &fileName : qAsConst(m_displayList)) {
541         if (fileName.isNull())
542             continue;
543 
544         if (m_thumbnailCache->contains(fileName))
545             continue;
546         const_cast<ThumbnailView::ThumbnailModel *>(this)->requestThumbnail(fileName, ImageManager::ThumbnailInvisible);
547     }
548 }
549 
stringWidth(const QString & text) const550 int ThumbnailView::ThumbnailModel::stringWidth(const QString &text) const
551 {
552     // This is a workaround for the deprecation warnings emerged with Qt 5.13.
553     // QFontMetrics::horizontalAdvance wasn't introduced until Qt 5.11. As soon as we drop support
554     // for Qt versions before 5.11, this can be removed in favor of calling horizontalAdvance
555     // directly.
556 #if (QT_VERSION < QT_VERSION_CHECK(5, 11, 0))
557     return QFontMetrics(widget()->font()).width(text);
558 #else
559     return QFontMetrics(widget()->font()).horizontalAdvance(text);
560 #endif
561 }
562 
563 // vi:expandtab:tabstop=4 shiftwidth=4:
564