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