File indexing completed on 2024-04-28 15:39:06

0001 // SPDX-FileCopyrightText: 2020-2024 Tobias Leupold <tl at stonemx dot de>
0002 //
0003 // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0004 
0005 // Local includes
0006 #include "ImagesListView.h"
0007 #include "SharedObjects.h"
0008 #include "ImagesModel.h"
0009 #include "Settings.h"
0010 #include "ImagesListFilter.h"
0011 
0012 // KDE includes
0013 #include <KLocalizedString>
0014 
0015 // Qt includes
0016 #include <QMouseEvent>
0017 #include <QApplication>
0018 #include <QMimeData>
0019 #include <QDrag>
0020 #include <QDebug>
0021 #include <QKeyEvent>
0022 #include <QMenu>
0023 #include <QDesktopServices>
0024 #include <QClipboard>
0025 #include <QRegularExpression>
0026 
0027 // C++ includes
0028 #include <algorithm>
0029 #include <functional>
0030 
0031 ImagesListView::ImagesListView(KGeoTag::ImagesListType type, SharedObjects *sharedObjects,
0032                                QWidget *parent)
0033     : QListView(parent),
0034       m_listType(type),
0035       m_bookmarks(sharedObjects->bookmarks())
0036 {
0037     viewport()->setAcceptDrops(true);
0038     setDropIndicatorShown(true);
0039     setDragDropMode(QAbstractItemView::DropOnly);
0040 
0041     m_listFilter = new ImagesListFilter(this, type);
0042     m_listFilter->setSourceModel(sharedObjects->imagesModel());
0043     setModel(m_listFilter);
0044     connect(m_listFilter, &ImagesListFilter::requestAddingImages,
0045             this, &ImagesListView::requestAddingImages);
0046     connect(m_listFilter, &ImagesListFilter::requestRemoveCoordinates,
0047             this, QOverload<const QVector<QString> &>::of(&ImagesListView::removeCoordinates));
0048 
0049     setSelectionMode(QAbstractItemView::ExtendedSelection);
0050     setContextMenuPolicy(Qt::CustomContextMenu);
0051     const int iconSize = sharedObjects->settings()->thumbnailSize();
0052     setIconSize(QSize(iconSize, iconSize));
0053 
0054     connect(this, &QAbstractItemView::clicked, this, &ImagesListView::centerImage);
0055     connect(this, &QAbstractItemView::clicked, this, &ImagesListView::imageSelected);
0056 
0057     // Context menu
0058 
0059     m_contextMenu = new QMenu(this);
0060 
0061     m_selectAll = m_contextMenu->addAction(i18n("Select all images"));
0062     m_selectAll->setIcon(QIcon::fromTheme(QStringLiteral("select")));
0063     connect(m_selectAll, &QAction::triggered, this, &QListView::selectAll);
0064 
0065     m_selectMenu = m_contextMenu->addMenu(i18n("Select"));
0066     m_selectMenu->menuAction()->setIcon(QIcon::fromTheme(QStringLiteral("select")));
0067     auto *all = m_selectMenu->addAction(i18n("All images"));
0068     connect(all, &QAction::triggered, this, &QListView::selectAll);
0069     auto *without = m_selectMenu->addAction(i18n("All images without coordinates"));
0070     connect(without, &QAction::triggered,
0071             this, std::bind(&ImagesListView::selectImages, this, false));
0072     auto *with = m_selectMenu->addAction(i18n("All images with coordinates"));
0073     connect(with, &QAction::triggered,
0074             this, std::bind(&ImagesListView::selectImages, this, true));
0075 
0076     m_contextMenu->addSeparator();
0077 
0078     m_automaticMatchingMenu = m_contextMenu->addMenu(i18n("Automatic matching"));
0079     m_automaticMatchingMenu->menuAction()->setIcon(QIcon::fromTheme(QStringLiteral("run-build")));
0080 
0081     auto *combinedMatchSearchAction = m_automaticMatchingMenu->addAction(
0082         i18n("Combined match search"));
0083     connect(combinedMatchSearchAction, &QAction::triggered,
0084             this, std::bind(&ImagesListView::requestAutomaticMatching, this,
0085                             this, KGeoTag::CombinedMatchSearch));
0086 
0087     m_automaticMatchingMenu->addSeparator();
0088 
0089     auto *searchExactMatchesAction = m_automaticMatchingMenu->addAction(
0090         i18n("Search exact matches only"));
0091     connect(searchExactMatchesAction, &QAction::triggered,
0092             this, std::bind(&ImagesListView::requestAutomaticMatching, this,
0093                             this, KGeoTag::ExactMatchSearch));
0094 
0095     auto *searchInterpolatedMatchesAction = m_automaticMatchingMenu->addAction(
0096         i18n("Search interpolated matches only"));
0097     connect(searchInterpolatedMatchesAction, &QAction::triggered,
0098             this, std::bind(&ImagesListView::requestAutomaticMatching, this,
0099                             this, KGeoTag::InterpolatedMatchSearch));
0100 
0101     m_bookmarksMenu = m_contextMenu->addMenu(i18n("Assign to bookmark"));
0102     m_bookmarksMenu->menuAction()->setIcon(QIcon::fromTheme(QStringLiteral("bookmarks")));
0103     updateBookmarks();
0104 
0105     m_contextMenu->addSeparator();
0106 
0107     m_assignToMapCenter = m_contextMenu->addAction(i18n("Assign to map center"));
0108     m_assignToMapCenter->setIcon(QIcon::fromTheme(QStringLiteral("crosshairs")));
0109     connect(m_assignToMapCenter, &QAction::triggered,
0110             this, std::bind(&ImagesListView::assignToMapCenter, this, this));
0111 
0112     m_assignToClipboard = m_contextMenu->addAction(i18n("Set coordinates from clipboard"));
0113     connect(m_assignToClipboard, &QAction::triggered, this, &ImagesListView::assignToClipboard);
0114 
0115     m_assignManually = m_contextMenu->addAction(i18n("Set coordinates manually"));
0116     m_assignManually->setIcon(QIcon::fromTheme(QStringLiteral("add-placemark")));
0117     connect(m_assignManually, &QAction::triggered,
0118             this, std::bind(&ImagesListView::assignManually, this, this));
0119 
0120     m_contextMenu->addSeparator();
0121 
0122     m_findClosestTrackPoint = m_contextMenu->addAction(i18n("Find closest trackpoint"));
0123     m_findClosestTrackPoint->setIcon(QIcon::fromTheme(QStringLiteral("edit-find")));
0124     connect(m_findClosestTrackPoint, &QAction::triggered,
0125             this, [this]
0126             {
0127                 Q_EMIT findClosestTrackPoint(currentIndex().data(KGeoTag::PathRole).toString());
0128             });
0129 
0130     m_editCoordinates = m_contextMenu->addAction(i18n("Edit coordinates"));
0131     connect(m_editCoordinates, &QAction::triggered,
0132             this, std::bind(&ImagesListView::editCoordinates, this, this));
0133 
0134     m_lookupElevation = m_contextMenu->addAction(i18n("Lookup elevation"));
0135     m_lookupElevation->setIcon(QIcon::fromTheme(QStringLiteral("adjustcurves")));
0136     connect(m_lookupElevation, &QAction::triggered,
0137             this, std::bind(&ImagesListView::lookupElevation, this, this));
0138 
0139     m_contextMenu->addSeparator();
0140 
0141     m_save = m_contextMenu->addAction(i18n("Save changes"));
0142     m_save->setIcon(QIcon::fromTheme(QStringLiteral("document-save")));
0143     connect(m_save, &QAction::triggered,
0144             this, std::bind(&ImagesListView::requestSaving, this, this));
0145 
0146     m_contextMenu->addSeparator();
0147 
0148     m_removeCoordinates = m_contextMenu->addAction(i18n("Remove coordinates"));
0149     connect(m_removeCoordinates, &QAction::triggered,
0150             this, std::bind(QOverload<ImagesListView *>::of(&ImagesListView::removeCoordinates),
0151                             this, this));
0152 
0153     m_contextMenu->addSeparator();
0154 
0155     m_discardChanges = m_contextMenu->addAction(i18n("Discard changes"));
0156     m_discardChanges->setIcon(QIcon::fromTheme(QStringLiteral("dialog-cancel")));
0157     connect(m_discardChanges, &QAction::triggered,
0158             this, std::bind(&ImagesListView::discardChanges, this, this));
0159 
0160     m_removeImages = m_contextMenu->addAction(i18np("Remove image", "Remove images", 1));
0161     m_removeImages->setIcon(QIcon::fromTheme(QStringLiteral("document-close")));
0162     connect(m_removeImages, &QAction::triggered,
0163             this, std::bind(&ImagesListView::removeImages, this, this));
0164 
0165     m_contextMenu->addSeparator();
0166 
0167     m_openExternally = m_contextMenu->addAction(i18n("Open with default viewer"));
0168     m_openExternally->setIcon(QIcon::fromTheme(QStringLiteral("file-zoom-in")));
0169     connect(m_openExternally, &QAction::triggered, this, &ImagesListView::openExternally);
0170 
0171     connect(this, &QListView::customContextMenuRequested, this, &ImagesListView::showContextMenu);
0172 }
0173 
0174 void ImagesListView::setListType(KGeoTag::ImagesListType type)
0175 {
0176     m_listType = type;
0177     m_listFilter->setListType(type);
0178     m_selectAll->setVisible(type != KGeoTag::AllImages);
0179     m_selectMenu->menuAction()->setVisible(type == KGeoTag::AllImages);
0180 }
0181 
0182 void ImagesListView::currentChanged(const QModelIndex &current, const QModelIndex &)
0183 {
0184     if (current.isValid()) {
0185         Q_EMIT imageSelected(current);
0186         scrollTo(current);
0187     }
0188 }
0189 
0190 void ImagesListView::updateBookmarks()
0191 {
0192     m_bookmarksMenu->clear();
0193     auto bookmarks = m_bookmarks->keys();
0194 
0195     if (bookmarks.count() == 0) {
0196         m_bookmarksMenu->addAction(i18n("(No bookmarks defined)"));
0197         return;
0198     }
0199 
0200     std::sort(bookmarks.begin(), bookmarks.end());
0201     for (const auto &label : std::as_const(bookmarks)) {
0202         auto *entry = m_bookmarksMenu->addAction(label);
0203         entry->setData(label);
0204         connect(entry, &QAction::triggered,
0205                 this, std::bind([this](QAction *action)
0206                 {
0207                     Q_EMIT assignTo(selectedPaths(), m_bookmarks->value(action->data().toString()));
0208                 },
0209                 entry));
0210     }
0211 }
0212 
0213 void ImagesListView::mousePressEvent(QMouseEvent *event)
0214 {
0215     const auto pos = event->pos();
0216     const auto selectedIndex = indexAt(pos);
0217     if (event->button() == Qt::LeftButton && selectedIndex.isValid()) {
0218         m_dragStarted = true;
0219         m_dragStartPosition = event->pos();
0220     } else {
0221         m_dragStarted = false;
0222     }
0223 
0224     QListView::mousePressEvent(event);
0225 }
0226 
0227 void ImagesListView::mouseMoveEvent(QMouseEvent *event)
0228 {
0229     // Enable selecting more images by dragging when the shift key is pressed
0230     if ((event->buttons() & Qt::LeftButton) && event->modifiers() == Qt::ShiftModifier) {
0231         QListView::mouseMoveEvent(event);
0232         return;
0233     }
0234 
0235     if (! (event->buttons() & Qt::LeftButton)
0236         || ! m_dragStarted
0237         || (event->pos() - m_dragStartPosition).manhattanLength()
0238            < QApplication::startDragDistance()) {
0239 
0240         return;
0241     }
0242 
0243     auto *drag = new QDrag(this);
0244     const auto paths = selectedPaths();
0245 
0246     if (paths.count() == 1) {
0247         drag->setPixmap(currentIndex().data(KGeoTag::ThumbnailRole).value<QPixmap>());
0248     }
0249 
0250     QMimeData *mimeData = new QMimeData;
0251     QList<QUrl> urls;
0252     for (const auto &path : paths) {
0253         urls.append(QUrl::fromLocalFile(path));
0254     }
0255     mimeData->setUrls(urls);
0256 
0257     mimeData->setData(KGeoTag::SourceImagesListMimeType,
0258                       KGeoTag::SourceImagesList.value(m_listType));
0259 
0260     drag->setMimeData(mimeData);
0261     drag->exec(Qt::MoveAction);
0262 }
0263 
0264 QVector<QString> ImagesListView::selectedPaths() const
0265 {
0266     QVector<QString> paths;
0267     const auto selected = selectedIndexes();
0268     for (const auto &index : selected) {
0269         paths.append(index.data(KGeoTag::PathRole).toString());
0270     }
0271     return paths;
0272 }
0273 
0274 void ImagesListView::keyPressEvent(QKeyEvent *event)
0275 {
0276     QListView::keyPressEvent(event);
0277 
0278     const auto key = event->key();
0279     if (! (key == Qt::Key_Up || key == Qt::Key_Down
0280            || key == Qt::Key_PageUp || key == Qt::Key_PageDown)) {
0281 
0282         return;
0283     }
0284 
0285     Q_EMIT centerImage(currentIndex());
0286 }
0287 
0288 void ImagesListView::showContextMenu(const QPoint &point)
0289 {
0290     const auto selected = selectedIndexes();
0291     const int allSelected = selected.count();
0292     const bool anySelected = allSelected > 0;
0293 
0294     m_selectMenu->setEnabled(model()->rowCount() > 0);
0295     m_selectAll->setEnabled(model()->rowCount() > 0);
0296 
0297     m_automaticMatchingMenu->setEnabled(anySelected);
0298     m_bookmarksMenu->setEnabled(anySelected);
0299     m_assignToMapCenter->setEnabled(anySelected);
0300     m_assignToClipboard->setEnabled(anySelected);
0301     m_assignManually->setEnabled(anySelected);
0302     m_editCoordinates->setEnabled(anySelected);
0303     m_findClosestTrackPoint->setEnabled(allSelected == 1);
0304     m_lookupElevation->setEnabled(anySelected);
0305     m_removeCoordinates->setEnabled(anySelected);
0306     m_discardChanges->setEnabled(anySelected);
0307     m_removeImages->setEnabled(anySelected);
0308     if (anySelected) {
0309         m_removeImages->setText(i18np("Remove image", "Remove images", allSelected));
0310     }
0311     m_openExternally->setEnabled(allSelected == 1);
0312 
0313     int hasCoordinates = 0;
0314     int changed = 0;
0315 
0316     for (const auto &index : selected) {
0317         if (index.data(KGeoTag::CoordinatesRole).value<Coordinates>().isSet()) {
0318             hasCoordinates++;
0319         }
0320 
0321         if (index.data(KGeoTag::ChangedRole).toBool()) {
0322             changed++;
0323         }
0324     }
0325 
0326     m_findClosestTrackPoint->setVisible(m_listType == KGeoTag::UnAssignedImages
0327                                         || m_listType == KGeoTag::AllImages);
0328     m_assignManually->setVisible(hasCoordinates == 0);
0329     m_editCoordinates->setVisible(hasCoordinates > 0);
0330     m_lookupElevation->setVisible(m_listType == KGeoTag::AssignedImages
0331                                   || m_listType == KGeoTag::AllImages);
0332     m_removeCoordinates->setVisible(hasCoordinates > 0);
0333     m_discardChanges->setVisible(changed > 0);
0334     m_save->setVisible(changed > 0);
0335 
0336     m_contextMenu->exec(mapToGlobal(point));
0337 }
0338 
0339 void ImagesListView::selectImages(bool coordinatesSet)
0340 {
0341     clearSelection();
0342     for (int i = 0; i < model()->rowCount(); i++) {
0343         const auto index = model()->index(i, 0);
0344         if (index.data(KGeoTag::CoordinatesRole).value<Coordinates>().isSet() == coordinatesSet) {
0345             selectionModel()->select(index, QItemSelectionModel::Select);
0346         }
0347     }
0348 }
0349 
0350 void ImagesListView::openExternally()
0351 {
0352     // selectedPaths() always contains exactly one entry when this is called,
0353     // because the connected action is only enabled in this very case
0354     QDesktopServices::openUrl(QUrl::fromLocalFile(selectedPaths().first()));
0355 }
0356 
0357 void ImagesListView::assignToClipboard()
0358 {
0359     const auto data = QGuiApplication::clipboard()->text().simplified();
0360     bool dataParsed = false;
0361     double lon = 0.0;
0362     double lat = 0.0;
0363 
0364     QRegularExpression re;
0365     QRegularExpressionMatch match;
0366 
0367     // Google Maps
0368     // Schema: xx.xxxxxxxxxxxxxx, xx.xxxxxxxxxxxxx
0369     if (! dataParsed) {
0370         re = QRegularExpression(QStringLiteral("^(\\d+\\.\\d+), (\\d+\\.\\d+)$"));
0371         match = re.match(data);
0372         if (match.hasMatch()) {
0373             bool lonOkay = false;
0374             bool latOkay = false;
0375             lat = match.captured(1).toDouble(&latOkay);
0376             lon = match.captured(2).toDouble(&lonOkay);
0377             dataParsed = latOkay && lonOkay;
0378         }
0379     }
0380 
0381     // OpenStreetMap Geo-URI
0382     // Schema: geo:xx.xxxxx,xx.xxxxx?z=xx
0383     if (! dataParsed) {
0384         re = QRegularExpression(QStringLiteral("^geo:(\\d+\\.\\d+),(\\d+\\.\\d+)\\?z=\\d+$"));
0385         match = re.match(data);
0386         if (match.hasMatch()) {
0387             bool latOkay = false;
0388             bool lonOkay = false;
0389             lat = match.captured(1).toDouble(&latOkay);
0390             lon = match.captured(2).toDouble(&lonOkay);
0391             dataParsed = latOkay && lonOkay;
0392         }
0393     }
0394 
0395     if (dataParsed) {
0396         Q_EMIT assignTo(selectedPaths(), Coordinates(lon, lat, 0.0, true));
0397     } else {
0398         Q_EMIT failedToParseClipboard();
0399     }
0400 }
0401 
0402 void ImagesListView::highlightImage(const QModelIndex &index)
0403 {
0404     const auto mappedIndex = m_listFilter->mapFromSource(index);
0405     scrollTo(mappedIndex);
0406     clearSelection();
0407     selectionModel()->select(mappedIndex, QItemSelectionModel::Select);
0408 }