// SPDX-License-Identifier: GPL-2.0 #include "desktop-widgets/locationinformation.h" #include "desktop-widgets/importgps.h" #include "core/subsurface-string.h" #include "desktop-widgets/mainwindow.h" #include "desktop-widgets/divelistview.h" #include "core/qthelper.h" #include "desktop-widgets/mapwidget.h" #include "core/color.h" #include "core/divefilter.h" #include "core/divesite.h" #include "core/divesitehelpers.h" #include "desktop-widgets/modeldelegates.h" #include "core/subsurface-qt/divelistnotifier.h" #include "core/taxonomy.h" #include "core/settings/qPrefUnit.h" #include "commands/command.h" #include #include #include #include #include #include #include LocationInformationWidget::LocationInformationWidget(QWidget *parent) : QGroupBox(parent), diveSite(nullptr), closeDistance(0) { ui.setupUi(this); ui.diveSiteMessage->setCloseButtonVisible(false); QAction *acceptAction = new QAction(tr("Done"), this); connect(acceptAction, &QAction::triggered, this, &LocationInformationWidget::acceptChanges); ui.diveSiteMessage->setText(tr("Dive site management")); ui.diveSiteMessage->addAction(acceptAction); connect(ui.geoCodeButton, SIGNAL(clicked()), this, SLOT(reverseGeocode())); ui.diveSiteCoordinates->installEventFilter(this); connect(&diveListNotifier, &DiveListNotifier::diveSiteChanged, this, &LocationInformationWidget::diveSiteChanged); connect(&diveListNotifier, &DiveListNotifier::diveSiteDeleted, this, &LocationInformationWidget::diveSiteDeleted); connect(qPrefUnits::instance(), &qPrefUnits::unit_systemChanged, this, &LocationInformationWidget::unitsChanged); unitsChanged(); ui.diveSiteListView->setModel(&filter_model); ui.diveSiteListView->setModelColumn(LocationInformationModel::NAME); ui.diveSiteListView->installEventFilter(this); } void LocationInformationWidget::keyPressEvent(QKeyEvent *e) { if (e->key() == Qt::Key_Escape) MainWindow::instance()->setFocus(); return QGroupBox::keyPressEvent(e); } bool LocationInformationWidget::eventFilter(QObject *object, QEvent *ev) { Q_UNUSED(object) if (ev->type() == QEvent::ContextMenu) { QContextMenuEvent *ctx = (QContextMenuEvent *)ev; QMenu contextMenu; contextMenu.addAction(tr("Merge into current site"), this, &LocationInformationWidget::mergeSelectedDiveSites); contextMenu.exec(ctx->globalPos()); return true; } return false; } void LocationInformationWidget::enableLocationButtons(bool enable) { ui.geoCodeButton->setEnabled(enable); } void LocationInformationWidget::mergeSelectedDiveSites() { if (!diveSite) return; const QModelIndexList selection = ui.diveSiteListView->selectionModel()->selectedIndexes(); QVector selected_dive_sites; selected_dive_sites.reserve(selection.count()); for (const QModelIndex &idx: selection) { dive_site *ds = idx.data(LocationInformationModel::DIVESITE_ROLE).value(); if (ds) selected_dive_sites.push_back(ds); } Command::mergeDiveSites(diveSite, selected_dive_sites); } // If we can't parse the coordinates, inform the user with a visual clue void LocationInformationWidget::coordinatesSetWarning(bool warn) { QPalette palette; if (warn) { palette.setColor(QPalette::Base, REDORANGE1_MED_TRANS); palette.setColor(QPalette::Text, WHITE1); } ui.diveSiteCoordinates->setPalette(palette); } void LocationInformationWidget::updateLabels() { if (!diveSite) { clearLabels(); return; } if (diveSite->name) ui.diveSiteName->setText(diveSite->name); else ui.diveSiteName->clear(); const char *country = taxonomy_get_country(&diveSite->taxonomy); if (country) ui.diveSiteCountry->setText(country); else ui.diveSiteCountry->clear(); if (diveSite->description) ui.diveSiteDescription->setText(diveSite->description); else ui.diveSiteDescription->clear(); if (diveSite->notes) ui.diveSiteNotes->setPlainText(diveSite->notes); else ui.diveSiteNotes->clear(); if (has_location(&diveSite->location)) ui.diveSiteCoordinates->setText(printGPSCoords(&diveSite->location)); else ui.diveSiteCoordinates->clear(); coordinatesSetWarning(false); ui.locationTags->setText(constructLocationTags(&diveSite->taxonomy, false)); } void LocationInformationWidget::unitsChanged() { if (prefs.units.length == units::METERS) { ui.diveSiteDistanceUnits->setText("m"); ui.diveSiteDistance->setText(QString::number(lrint(closeDistance / 1000.0))); } else { ui.diveSiteDistanceUnits->setText("ft"); ui.diveSiteDistance->setText(QString::number(lrint(mm_to_feet(closeDistance)))); } } void LocationInformationWidget::diveSiteChanged(struct dive_site *ds, int field) { if (diveSite != ds) return; // A different dive site was changed -> do nothing. switch (field) { case LocationInformationModel::NAME: ui.diveSiteName->setText(diveSite->name); return; case LocationInformationModel::DESCRIPTION: ui.diveSiteDescription->setText(diveSite->description); return; case LocationInformationModel::NOTES: ui.diveSiteNotes->setText(diveSite->notes); return; case LocationInformationModel::TAXONOMY: ui.diveSiteCountry->setText(taxonomy_get_country(&diveSite->taxonomy)); ui.locationTags->setText(constructLocationTags(&diveSite->taxonomy, false)); return; case LocationInformationModel::LOCATION: filter_model.setCoordinates(diveSite->location); if (has_location(&diveSite->location)) { enableLocationButtons(true); ui.diveSiteCoordinates->setText(printGPSCoords(&diveSite->location)); } else { enableLocationButtons(false); ui.diveSiteCoordinates->clear(); } coordinatesSetWarning(false); return; default: return; } } void LocationInformationWidget::clearLabels() { ui.diveSiteName->clear(); ui.diveSiteCountry->clear(); ui.diveSiteDescription->clear(); ui.diveSiteNotes->clear(); ui.diveSiteCoordinates->clear(); coordinatesSetWarning(false); ui.locationTags->clear(); } // Parse GPS text into location_t static location_t parseGpsText(const QString &text) { double lat, lon; if (parseGpsText(text.trimmed(), &lat, &lon)) return create_location(lat, lon); return zero_location; } // Check if GPS text is parseable static bool validateGpsText(const QString &textIn) { double lat, lon; QString text = textIn.trimmed(); return text.isEmpty() || parseGpsText(text.trimmed(), &lat, &lon); } void LocationInformationWidget::diveSiteDeleted(struct dive_site *ds, int) { // If the currently edited dive site was removed under our feet, close the widget. // This will reset the dangling pointer. if (ds && ds == diveSite) acceptChanges(); } void LocationInformationWidget::acceptChanges() { closeDistance = 0; MainWindow::instance()->diveList->setEnabled(true); MainWindow::instance()->setEnabledToolbar(true); MainWindow::instance()->setApplicationState(MainWindow::ApplicationState::Default); DiveFilter::instance()->stopFilterDiveSites(); // Subtlety alert: diveSite must be cleared *after* exiting the dive-site mode. // Exiting dive-site mode removes the focus from the active widget and // thus fires the corresponding editingFinished signal, which in turn creates // an undo-command. To set an undo-command, the widget has to know the // currently edited dive-site. diveSite = nullptr; } void LocationInformationWidget::initFields(dive_site *ds) { diveSite = ds; if (ds) { filter_model.set(ds, ds->location); updateLabels(); enableLocationButtons(dive_site_has_gps_location(ds)); DiveFilter::instance()->startFilterDiveSites(QVector{ ds }); filter_model.invalidate(); } else { filter_model.set(0, zero_location); clearLabels(); } } void LocationInformationWidget::on_GPSbutton_clicked() { QFileInfo finfo(system_default_directory()); QString fileName = QFileDialog::getOpenFileName(this, tr("Select GPS file to open"), finfo.absolutePath(), tr("GPS files (*.gpx *.GPX)")); if (fileName.isEmpty()) return; ImportGPS GPSDialog(this, fileName, &ui); // Create a GPS import QDialog GPSDialog.coords.start_dive = current_dive->when; // initialise GPSDialog.coords.end_dive = dive_endtime(current_dive); if (getCoordsFromGPXFile(&GPSDialog.coords, fileName) == 0) { // Get coordinates from GPS file GPSDialog.updateUI(); // If successful, put results in Dialog if (!GPSDialog.exec()) // and show QDialog return; } } void LocationInformationWidget::on_diveSiteCoordinates_editingFinished() { if (diveSite && validateGpsText(ui.diveSiteCoordinates->text())) Command::editDiveSiteLocation(diveSite, parseGpsText(ui.diveSiteCoordinates->text())); } void LocationInformationWidget::on_diveSiteCoordinates_textEdited(const QString &s) { coordinatesSetWarning(!validateGpsText(s)); } void LocationInformationWidget::on_diveSiteCountry_editingFinished() { if (diveSite) Command::editDiveSiteCountry(diveSite, ui.diveSiteCountry->text()); } void LocationInformationWidget::on_diveSiteDescription_editingFinished() { if (diveSite) Command::editDiveSiteDescription(diveSite, ui.diveSiteDescription->text()); } void LocationInformationWidget::on_diveSiteName_editingFinished() { if (diveSite) Command::editDiveSiteName(diveSite, ui.diveSiteName->text()); } void LocationInformationWidget::on_diveSiteNotes_editingFinished() { if (diveSite) Command::editDiveSiteNotes(diveSite, ui.diveSiteNotes->toPlainText()); } void LocationInformationWidget::on_diveSiteDistance_textChanged(const QString &s) { bool ok; uint64_t d = s.toLongLong(&ok); if (!ok) d = 0; closeDistance = prefs.units.length == units::METERS ? d * 1000 : feet_to_mm(d); filter_model.setDistance(closeDistance); } void LocationInformationWidget::reverseGeocode() { dive_site *ds = diveSite; /* Save local copy; possibility of user closing the widget while reverseGeoLookup is running (see #2930) */ location_t location = parseGpsText(ui.diveSiteCoordinates->text()); if (!ds || !has_location(&location)) return; taxonomy_data taxonomy = reverseGeoLookup(location.lat, location.lon); if (ds != diveSite) { free_taxonomy(&taxonomy); return; } // This call transfers ownership of the taxonomy memory into an EditDiveSiteTaxonomy object Command::editDiveSiteTaxonomy(ds, taxonomy); } DiveLocationFilterProxyModel::DiveLocationFilterProxyModel(QObject *) : currentLocation(zero_location) { } void DiveLocationFilterProxyModel::setFilter(const QString &filterIn) { filter = filterIn; invalidate(); sort(LocationInformationModel::NAME); } void DiveLocationFilterProxyModel::setCurrentLocation(location_t loc) { currentLocation = loc; sort(LocationInformationModel::NAME); } bool DiveLocationFilterProxyModel::filterAcceptsRow(int source_row, const QModelIndex&) const { // We don't want to show the first two entries (add dive site with that name) // if there is no filter text. if (filter.isEmpty() && source_row <= 1) return false; if (source_row == 0) return true; QString sourceString = sourceModel()->index(source_row, LocationInformationModel::NAME).data(Qt::DisplayRole).toString(); return sourceString.contains(filter, Qt::CaseInsensitive); } bool DiveLocationFilterProxyModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const { // The first two entries are special - we never want to change their order if (source_left.row() <= 1 || source_right.row() <= 1) return source_left.row() < source_right.row(); // If there is a current location, sort by that - otherwise use the provided column if (has_location(¤tLocation)) { // The dive sites are -2 because of the first two items. struct dive_site *ds1 = get_dive_site(source_left.row() - 2, &dive_site_table); struct dive_site *ds2 = get_dive_site(source_right.row() - 2, &dive_site_table); return get_distance(&ds1->location, ¤tLocation) < get_distance(&ds2->location, ¤tLocation); } return source_left.data().toString().compare(source_right.data().toString(), Qt::CaseInsensitive) < 0; } DiveLocationModel::DiveLocationModel(QObject *) { resetModel(); } void DiveLocationModel::resetModel() { beginResetModel(); endResetModel(); } QVariant DiveLocationModel::data(const QModelIndex &index, int role) const { static const QIcon plusIcon(":list-add-icon"); static const QIcon geoCode(":geotag-icon"); if (index.row() <= 1) { // two special cases. if (index.column() == LocationInformationModel::DIVESITE) return QVariant::fromValue(RECENTLY_ADDED_DIVESITE); switch (role) { case Qt::DisplayRole: return new_ds_value[index.row()]; case Qt::ToolTipRole: return current_dive && current_dive->dive_site ? tr("Create a new dive site, copying relevant information from the current dive.") : tr("Create a new dive site with this name"); case Qt::DecorationRole: return plusIcon; } return QVariant(); } // The dive sites are -2 because of the first two items. struct dive_site *ds = get_dive_site(index.row() - 2, &dive_site_table); return LocationInformationModel::getDiveSiteData(ds, index.column(), role); } int DiveLocationModel::columnCount(const QModelIndex&) const { return LocationInformationModel::COLUMNS; } int DiveLocationModel::rowCount(const QModelIndex&) const { return dive_site_table.nr + 2; } bool DiveLocationModel::setData(const QModelIndex &index, const QVariant &value, int) { if (!index.isValid()) return false; if (index.row() > 1) return false; new_ds_value[index.row()] = value.toString(); dataChanged(index, index); return true; } DiveLocationLineEdit::DiveLocationLineEdit(QWidget *parent) : QLineEdit(parent), proxy(new DiveLocationFilterProxyModel()), model(new DiveLocationModel()), view(new DiveLocationListView()), currDs(nullptr) { proxy->setSourceModel(model); proxy->setFilterKeyColumn(LocationInformationModel::NAME); view->setModel(proxy); view->setModelColumn(LocationInformationModel::NAME); view->setItemDelegate(&delegate); view->setEditTriggers(QAbstractItemView::NoEditTriggers); view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view->setSelectionBehavior(QAbstractItemView::SelectRows); view->setSelectionMode(QAbstractItemView::SingleSelection); view->setParent(0, Qt::Popup); view->installEventFilter(this); view->setFocusPolicy(Qt::NoFocus); view->setFocusProxy(this); view->setMouseTracking(true); connect(this, &QLineEdit::textEdited, this, &DiveLocationLineEdit::setTemporaryDiveSiteName); connect(view, &QAbstractItemView::activated, this, &DiveLocationLineEdit::itemActivated); connect(view, &QAbstractItemView::entered, this, &DiveLocationLineEdit::entered); connect(view, &DiveLocationListView::currentIndexChanged, this, &DiveLocationLineEdit::currentChanged); } bool DiveLocationLineEdit::eventFilter(QObject *, QEvent *e) { if (e->type() == QEvent::KeyPress) { QKeyEvent *keyEv = (QKeyEvent *)e; if (keyEv->key() == Qt::Key_Escape) { view->hide(); return true; } if (keyEv->key() == Qt::Key_Return || keyEv->key() == Qt::Key_Enter) { #if __APPLE__ // for some reason it seems like on a Mac hitting return/enter // doesn't call 'activated' for that index. so let's do it manually if (view->currentIndex().isValid()) itemActivated(view->currentIndex()); #endif view->hide(); return false; } if (keyEv->key() == Qt::Key_Tab) { itemActivated(view->currentIndex()); view->hide(); return false; } event(e); } else if (e->type() == QEvent::MouseButtonPress) { if (!view->underMouse()) { view->hide(); return true; } } else if (e->type() == QEvent::InputMethod) { this->inputMethodEvent(static_cast(e)); } return false; } void DiveLocationLineEdit::focusOutEvent(QFocusEvent *ev) { if (!view->isVisible()) QLineEdit::focusOutEvent(ev); } void DiveLocationLineEdit::itemActivated(const QModelIndex &index) { QModelIndex idx = index; if (index.column() == LocationInformationModel::DIVESITE) idx = index.model()->index(index.row(), LocationInformationModel::NAME); dive_site *ds = index.model()->index(index.row(), LocationInformationModel::DIVESITE).data().value(); currDs = ds; setText(idx.data().toString()); if (view->isVisible()) view->hide(); emit diveSiteSelected(); } void DiveLocationLineEdit::refreshDiveSiteCache() { model->resetModel(); } static struct dive_site *get_dive_site_name_start_which_str(const QString &str) { struct dive_site *ds; int i; for_each_dive_site (i, ds, &dive_site_table) { QString dsName(ds->name); if (dsName.toLower().startsWith(str.toLower())) { return ds; } } return NULL; } void DiveLocationLineEdit::setTemporaryDiveSiteName(const QString &name) { // This function fills the first two entries with potential names of // a dive site to be generated. The first entry is simply the entered // text. The second entry is the first known dive site name starting // with the entered text. QModelIndex i0 = model->index(0, LocationInformationModel::NAME); QModelIndex i1 = model->index(1, LocationInformationModel::NAME); model->setData(i0, name); // Note: if i1_name stays empty, the line will automatically // be filtered out by the proxy filter, as it does not contain // the user entered text. QString i1_name; if (struct dive_site *ds = get_dive_site_name_start_which_str(name)) { const QString orig_name = QString(ds->name).toLower(); const QString new_name = name.toLower(); if (new_name != orig_name) i1_name = QString(ds->name); } model->setData(i1, i1_name); proxy->setFilter(name); fixPopupPosition(); if (!view->isVisible()) view->show(); } void DiveLocationLineEdit::keyPressEvent(QKeyEvent *ev) { QLineEdit::keyPressEvent(ev); if (ev->key() != Qt::Key_Left && ev->key() != Qt::Key_Right && ev->key() != Qt::Key_Escape && ev->key() != Qt::Key_Return) { if (ev->key() != Qt::Key_Up && ev->key() != Qt::Key_Down) currDs = RECENTLY_ADDED_DIVESITE; else showPopup(); } else if (ev->key() == Qt::Key_Escape) { view->hide(); } } void DiveLocationLineEdit::fixPopupPosition() { const QRect screen = QApplication::desktop()->availableGeometry(this); const int maxVisibleItems = 5; QPoint pos; int rh, w; int h = (view->sizeHintForRow(0) * qMin(maxVisibleItems, view->model()->rowCount()) + 3) + 3; QScrollBar *hsb = view->horizontalScrollBar(); if (hsb && hsb->isVisible()) h += view->horizontalScrollBar()->sizeHint().height(); rh = height(); pos = mapToGlobal(QPoint(0, height() - 2)); w = width(); if (w > screen.width()) w = screen.width(); if ((pos.x() + w) > (screen.x() + screen.width())) pos.setX(screen.x() + screen.width() - w); if (pos.x() < screen.x()) pos.setX(screen.x()); int top = pos.y() - rh - screen.top() + 2; int bottom = screen.bottom() - pos.y(); h = qMax(h, view->minimumHeight()); if (h > bottom) { h = qMin(qMax(top, bottom), h); if (top > bottom) pos.setY(pos.y() - h - rh + 2); } view->setGeometry(pos.x(), pos.y(), w, h); if (!view->currentIndex().isValid() && view->model()->rowCount()) { view->setCurrentIndex(view->model()->index(0, 1)); } } void DiveLocationLineEdit::setCurrentDiveSite(struct dive *d) { location_t currentLocation; if (d) { currDs = get_dive_site_for_dive(d); currentLocation = dive_get_gps_location(d); } else { currDs = nullptr; currentLocation = zero_location; } if (!currDs) clear(); else setText(currDs->name); proxy->setCurrentLocation(currentLocation); delegate.setCurrentLocation(currentLocation); } void DiveLocationLineEdit::showPopup() { if (!view->isVisible()) setTemporaryDiveSiteName(text()); } void DiveLocationLineEdit::showAllSites() { if (!view->isVisible()) { // By setting the "temporary dive site name" to the empty string, // all dive sites are shown sorted by distance from the site of // the current dive. setTemporaryDiveSiteName(QString()); // By selecting the whole text, the user can immediately start // typing to activate the full-text filter. selectAll(); } } struct dive_site *DiveLocationLineEdit::currDiveSite() const { // If there is no text, this corresponds to the empty dive site return text().trimmed().isEmpty() ? nullptr : currDs; } DiveLocationListView::DiveLocationListView(QWidget*) { } void DiveLocationListView::currentChanged(const QModelIndex ¤t, const QModelIndex &previous) { QListView::currentChanged(current, previous); emit currentIndexChanged(current); }