From a00e27638fb049ca2436a9aacbb58658908677ec Mon Sep 17 00:00:00 2001 From: Joachim Kohlhammer Date: Sat, 17 Jan 2026 18:36:34 +0100 Subject: [PATCH] Dialog to build filter queries for similar activities (#4805) * list all (non-zero) fields and metrics * filter list by field / metric name * select an operator per field (ignore, equals, contains, larger than, ...) * available in the context menus of activity- and calendar view --- src/Charts/CalendarWindow.cpp | 10 + src/Core/Utils.cpp | 25 ++ src/Core/Utils.h | 1 + src/Gui/AnalysisSidebar.cpp | 13 + src/Gui/Calendar.cpp | 10 +- src/Gui/Calendar.h | 4 + src/Gui/FilterSimilarDialog.cpp | 365 +++++++++++++++++++++++++++++ src/Gui/FilterSimilarDialog.h | 55 +++++ src/Gui/MainWindow.cpp | 14 ++ src/Gui/MainWindow.h | 2 + src/Gui/StyledItemDelegates.cpp | 78 +++++- src/Gui/StyledItemDelegates.h | 17 +- src/src.pro | 4 +- unittests/Core/utils/testUtils.cpp | 23 ++ unittests/Core/utils/utils.pro | 6 + unittests/unittests.pro | 1 + 16 files changed, 609 insertions(+), 19 deletions(-) create mode 100644 src/Gui/FilterSimilarDialog.cpp create mode 100644 src/Gui/FilterSimilarDialog.h create mode 100644 unittests/Core/utils/testUtils.cpp create mode 100644 unittests/Core/utils/utils.pro diff --git a/src/Charts/CalendarWindow.cpp b/src/Charts/CalendarWindow.cpp index 8099fa113..9dfd03774 100644 --- a/src/Charts/CalendarWindow.cpp +++ b/src/Charts/CalendarWindow.cpp @@ -31,6 +31,7 @@ #include "RepeatScheduleWizard.h" #include "WorkoutFilter.h" #include "IconManager.h" +#include "FilterSimilarDialog.h" #include "SeasonDialogs.h" #include "SaveDialogs.h" @@ -333,6 +334,15 @@ CalendarWindow::CalendarWindow(Context *context) } } }); + connect(calendar, &Calendar::filterSimilar, this, [this](CalendarEntry activity) { + for (RideItem *rideItem : this->context->athlete->rideCache->rides()) { + if (rideItem != nullptr && rideItem->fileName == activity.reference) { + FilterSimilarDialog dlg(this->context, rideItem, this); + dlg.exec(); + break; + } + } + }); connect(calendar, &Calendar::linkActivity, this, &CalendarWindow::linkActivities); connect(calendar, &Calendar::unlinkActivity, this, &CalendarWindow::unlinkActivities); connect(calendar, &Calendar::viewActivity, this, [this](CalendarEntry activity) { diff --git a/src/Core/Utils.cpp b/src/Core/Utils.cpp index e4d9848bd..259076429 100644 --- a/src/Core/Utils.cpp +++ b/src/Core/Utils.cpp @@ -162,6 +162,31 @@ QString xmlprotect(const QString &string) return s; } +QString quoteEscape(const QString &string) +{ + QString out; + out.reserve(string.size() * 2); + int backslashes = 0; + for (QChar c : string) { + if (c == '\\') { + backslashes++; + out += c; + } else if (c == '"') { + if (backslashes % 2 == 0) { + out += "\\\""; + } else { + out += '"'; + } + backslashes = 0; + } else { + out += c; + backslashes = 0; + } + } + out.squeeze(); + return out; +} + QString unescape(const QString &string) { // just unescape common \ characters diff --git a/src/Core/Utils.h b/src/Core/Utils.h index 1500eb37c..e9b6f4e78 100644 --- a/src/Core/Utils.h +++ b/src/Core/Utils.h @@ -40,6 +40,7 @@ namespace Utils { QString xmlprotect(const QString &string); QString unprotect(const QString &buffer); + QString quoteEscape(const QString &string); // escape quotes (" -> \", \" -> \", \\" -> \\\", ...) QString unescape(const QString &string); // simple string unescaping, used in datafilters QString jsonprotect(const QString &buffer); QString jsonunprotect(const QString &buffer); diff --git a/src/Gui/AnalysisSidebar.cpp b/src/Gui/AnalysisSidebar.cpp index 7f6eba5a5..2b80d1090 100644 --- a/src/Gui/AnalysisSidebar.cpp +++ b/src/Gui/AnalysisSidebar.cpp @@ -42,6 +42,9 @@ // Show in Train Mode #include "WorkoutFilter.h" +// Filter for similar activities +#include "FilterSimilarDialog.h" + AnalysisSidebar::AnalysisSidebar(Context *context) : QWidget(context->mainWindow), context(context) { QVBoxLayout *mainLayout = new QVBoxLayout(this); @@ -412,6 +415,16 @@ AnalysisSidebar::showActivityMenu(const QPoint &pos) connect(actFindBest, SIGNAL(triggered(void)), this, SLOT(addIntervals(void))); menu.addAction(actFindBest); + QAction *filterSimilar = new QAction(tr("Filter similar activities..."), rideNavigator); + connect(filterSimilar, &QAction::triggered, this, [this]() { + if (context->ride != nullptr) { + FilterSimilarDialog dlg(context, context->ride, this); + dlg.exec(); + } + }); + menu.addAction(filterSimilar); + + if (rideItem->planned && rideItem->sport == "Bike") { QString filter = buildWorkoutFilter(rideItem); if (! filter.isEmpty()) { diff --git a/src/Gui/Calendar.cpp b/src/Gui/Calendar.cpp index 564c0396b..944edf30e 100644 --- a/src/Gui/Calendar.cpp +++ b/src/Gui/Calendar.cpp @@ -184,6 +184,8 @@ CalendarBaseTable::buildContextMenu contextMenu->addAction(tr("Unlink from planned activity"), this, [this, entry]() { emit unlinkActivity(entry); }); } contextMenu->addSeparator(); + contextMenu->addAction(tr("Filter similar activities..."), this, [this, entry]() { emit filterSimilar(entry); }); + contextMenu->addSeparator(); contextMenu->addAction(tr("Delete completed activity"), this, [this, entry]() { emit delActivity(entry); }); break; case ENTRY_TYPE_PLANNED_ACTIVITY: @@ -203,10 +205,11 @@ CalendarBaseTable::buildContextMenu } else { contextMenu->addAction(tr("Mark as incomplete"), this, [this, entry]() { emit unlinkActivity(entry); }); } + contextMenu->addSeparator(); if (entry.hasTrainMode) { - contextMenu->addSeparator(); contextMenu->addAction(tr("Show in train mode..."), this, [this, entry]() { emit showInTrainMode(entry); }); } + contextMenu->addAction(tr("Filter similar activities..."), this, [this, entry]() { emit filterSimilar(entry); }); contextMenu->addSeparator(); contextMenu->addAction(tr("Delete planned activity"), this, [this, entry]() { emit delActivity(entry); }); break; @@ -1543,6 +1546,7 @@ CalendarDayView::CalendarDayView connect(dayTable, &CalendarDayTable::viewLinkedActivity, this, &CalendarDayView::viewLinkedActivity); connect(dayTable, &CalendarDayTable::addActivity, this, &CalendarDayView::addActivity); connect(dayTable, &CalendarDayTable::showInTrainMode, this, &CalendarDayView::showInTrainMode); + connect(dayTable, &CalendarDayTable::filterSimilar, this, &CalendarDayView::filterSimilar); connect(dayTable, &CalendarDayTable::delActivity, this, &CalendarDayView::delActivity); connect(dayTable, &CalendarDayTable::saveChanges, this, &CalendarDayView::saveChanges); connect(dayTable, &CalendarDayTable::discardChanges, this, &CalendarDayView::discardChanges); @@ -1842,6 +1846,7 @@ CalendarWeekView::CalendarWeekView connect(weekTable, &CalendarDayTable::viewLinkedActivity, this, &CalendarWeekView::viewLinkedActivity); connect(weekTable, &CalendarDayTable::addActivity, this, &CalendarWeekView::addActivity); connect(weekTable, &CalendarDayTable::showInTrainMode, this, &CalendarWeekView::showInTrainMode); + connect(weekTable, &CalendarDayTable::filterSimilar, this, &CalendarWeekView::filterSimilar); connect(weekTable, &CalendarDayTable::delActivity, this, &CalendarWeekView::delActivity); connect(weekTable, &CalendarDayTable::saveChanges, this, &CalendarWeekView::saveChanges); connect(weekTable, &CalendarDayTable::discardChanges, this, &CalendarWeekView::discardChanges); @@ -2039,6 +2044,7 @@ Calendar::Calendar connect(dayView, &CalendarDayView::viewLinkedActivity, this, &Calendar::viewLinkedActivity); connect(dayView, &CalendarDayView::addActivity, this, &Calendar::addActivity); connect(dayView, &CalendarDayView::showInTrainMode, this, &Calendar::showInTrainMode); + connect(dayView, &CalendarDayView::filterSimilar, this, &Calendar::filterSimilar); connect(dayView, &CalendarDayView::delActivity, this, &Calendar::delActivity); connect(dayView, &CalendarDayView::saveChanges, this, &Calendar::saveChanges); connect(dayView, &CalendarDayView::discardChanges, this, &Calendar::discardChanges); @@ -2067,6 +2073,7 @@ Calendar::Calendar connect(weekView, &CalendarWeekView::viewLinkedActivity, this, &Calendar::viewLinkedActivity); connect(weekView, &CalendarWeekView::addActivity, this, &Calendar::addActivity); connect(weekView, &CalendarWeekView::showInTrainMode, this, &Calendar::showInTrainMode); + connect(weekView, &CalendarWeekView::filterSimilar, this, &Calendar::filterSimilar); connect(weekView, &CalendarWeekView::delActivity, this, &Calendar::delActivity); connect(weekView, &CalendarWeekView::saveChanges, this, &Calendar::saveChanges); connect(weekView, &CalendarWeekView::discardChanges, this, &Calendar::discardChanges); @@ -2093,6 +2100,7 @@ Calendar::Calendar setView(CalendarView::Day); }); connect(monthView, &CalendarMonthTable::showInTrainMode, this, &Calendar::showInTrainMode); + connect(monthView, &CalendarMonthTable::filterSimilar, this, &Calendar::filterSimilar); connect(monthView, &CalendarMonthTable::linkActivity, this, &Calendar::linkActivity); connect(monthView, &CalendarMonthTable::unlinkActivity, this, &Calendar::unlinkActivity); connect(monthView, &CalendarMonthTable::viewActivity, this, &Calendar::viewActivity); diff --git a/src/Gui/Calendar.h b/src/Gui/Calendar.h index da87bc637..bda377b86 100644 --- a/src/Gui/Calendar.h +++ b/src/Gui/Calendar.h @@ -68,6 +68,7 @@ public: signals: void showInTrainMode(CalendarEntry ctivity); + void filterSimilar(CalendarEntry activity); void linkActivity(CalendarEntry activity, bool autoLink); void unlinkActivity(CalendarEntry activity); void viewActivity(CalendarEntry activity); @@ -262,6 +263,7 @@ signals: void dayChanged(QDate date); void showInTrainMode(CalendarEntry activity); + void filterSimilar(CalendarEntry activity); void linkActivity(CalendarEntry activity, bool autoLink); void unlinkActivity(CalendarEntry activity); void viewActivity(CalendarEntry activity); @@ -315,6 +317,7 @@ signals: void dayChanged(QDate date); void showInTrainMode(CalendarEntry activity); + void filterSimilar(CalendarEntry activity); void linkActivity(CalendarEntry activity, bool autoLink); void unlinkActivity(CalendarEntry activity); void viewActivity(CalendarEntry activity); @@ -381,6 +384,7 @@ signals: void moveActivity(CalendarEntry activity, QDate srcDay, QDate destDay, QTime destTime); void showInTrainMode(CalendarEntry activity); + void filterSimilar(CalendarEntry activity); void linkActivity(CalendarEntry activity, bool autoLink); void unlinkActivity(CalendarEntry activity); void viewActivity(CalendarEntry activity); diff --git a/src/Gui/FilterSimilarDialog.cpp b/src/Gui/FilterSimilarDialog.cpp new file mode 100644 index 000000000..ed182bb68 --- /dev/null +++ b/src/Gui/FilterSimilarDialog.cpp @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2025 Joachim Kohlhammer (joachim.kohlhammer@gmx.de) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "FilterSimilarDialog.h" + +#include +#include + +#include "RideMetadata.h" +#include "Utils.h" +#include "MainWindow.h" +#include "Colors.h" +#include "StyledItemDelegates.h" + + +FilterSimilarDialog::FilterSimilarDialog +(Context *context, RideItem const * const rideItem, QWidget *parent) +: QDialog(parent), context(context), rideItem(rideItem) +{ + setWindowTitle(tr("Filter for similar activities")); + setMinimumSize(800 * dpiXFactor, 800 * dpiYFactor); + resize(800 * dpiXFactor, 800 * dpiYFactor); + + hideZeroed = new QCheckBox(tr("Hide zeroed fields and metrics")); + hideZeroed->setChecked(true); + + filterTreeEdit = new QLineEdit(); + filterTreeEdit->setClearButtonEnabled(true); + filterTreeEdit->setPlaceholderText(tr("Find fields and metrics by name...")); + + tree = new QTreeWidget(); + ComboBoxDelegate *operatorDelegate = new ComboBoxDelegate(tree); + operatorDelegate->setRoleForType(Qt::UserRole + 1); + operatorDelegate->addItemsForType(0, { "—", tr("equals"), tr("contains") }); + operatorDelegate->addItemsForType(1, { "—", "<", "≤", "=", "≈", "≥", ">" }); + tree->setColumnCount(3); + tree->setHeaderHidden(true); + tree->setMouseTracking(true); + tree->viewport()->installEventFilter(this); + basicTreeWidgetStyle(tree, true); + tree->setItemDelegateForColumn(0, new NoEditDelegate(tree)); + tree->setItemDelegateForColumn(1, operatorDelegate); + tree->setItemDelegateForColumn(2, new NoEditDelegate(tree)); + tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + tree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + + addFields(); + addMetrics(); + + preview = new QTextEdit(); + preview->setReadOnly(true); + + QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + connect(tree, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem *item, int column) { + Q_UNUSED(column) + + QFont font = tree->font(); + if (item->data(1, Qt::DisplayRole).toInt() == 0) { + item->setData(0, Qt::FontRole, font); + item->setData(2, Qt::FontRole, font); + font.setWeight(QFont::Thin); + item->setData(1, Qt::FontRole, font); + } else { + font.setWeight(QFont::Bold); + item->setData(0, Qt::FontRole, font); + item->setData(1, Qt::FontRole, font); + item->setData(2, Qt::FontRole, font); + } + + QString filter = buildFilter(); + preview->setText(filter); + updateFilterTree(); + }); + connect(tree, &QTreeWidget::clicked, this, [this](const QModelIndex &index) { + if (index.column() == 1) { + tree->edit(index); + } + }); + connect(hideZeroed, &QCheckBox::toggled, this, &FilterSimilarDialog::updateFilterTree); + connect(filterTreeEdit, &QLineEdit::textChanged, this, &FilterSimilarDialog::updateFilterTree); + connect(buttons, &QDialogButtonBox::accepted, this, [this, context]() { + QString filter = buildFilter(); + if (! filter.isEmpty()) { + context->mainWindow->fillinFilter(filter); + } + accept(); + }); + connect(buttons, &QDialogButtonBox::rejected, this, [this]() { + reject(); + }); + + QHBoxLayout *confLayout = new QHBoxLayout(); + confLayout->addWidget(filterTreeEdit); + confLayout->addSpacing(20 * dpiXFactor); + confLayout->addWidget(hideZeroed); + + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->addLayout(confLayout); + mainLayout->addSpacing(10 * dpiYFactor); + mainLayout->addWidget(tree, 10); + mainLayout->addSpacing(20 * dpiYFactor); + mainLayout->addWidget(new QLabel(tr("Generated Filter:"))); + mainLayout->addWidget(preview, 2); + mainLayout->addSpacing(20 * dpiYFactor); + mainLayout->addWidget(buttons); + + updateFilterTree(); +} + + +bool +FilterSimilarDialog::eventFilter +(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::MouseMove) { + QMouseEvent *mouseEvent = static_cast(event); + QModelIndex index = tree->indexAt(mouseEvent->pos()); + if (index.isValid() && index.column() == 1) { + tree->viewport()->setCursor(Qt::PointingHandCursor); + } else { + tree->viewport()->setCursor(Qt::ArrowCursor); + } + } + return QDialog::eventFilter(obj, event); +} + + +QString +FilterSimilarDialog::buildFilter +() const +{ + bool first = true; + QString filter; + for (int i = 0; i < tree->topLevelItemCount(); ++i) { + QTreeWidgetItem *section = tree->topLevelItem(i); + for (int j = 0; j < section->childCount(); ++j) { + QTreeWidgetItem *item = section->child(j); + int op = item->data(1, Qt::DisplayRole).toInt(); + if (op != 0) { + if (! first) { + filter += " and "; + } + int type = item->data(1, Qt::UserRole + 1).toInt(); + QString opStr = "="; + bool needQuotes = true; + bool similar = false; + QString similarLow; + QString similarHigh; + if (type == 0) { + if (op == 1) { + opStr = "="; + needQuotes = true; + } else if (op == 2) { + opStr = " contains "; + needQuotes = true; + } + } else if (type == 1) { + if (op == 1) { + opStr = "<"; + needQuotes = false; + } else if (op == 2) { + opStr = "<="; + needQuotes = false; + } else if (op == 3) { + opStr = "="; + needQuotes = true; + } else if (op == 4) { + GcFieldType detailType = static_cast(item->data(1, Qt::UserRole + 2).value()); + double value = item->data(0, Qt::UserRole + 2).toString().toDouble(); + double low = value * 0.95; + double high = value * 1.05; + if (value < 0) { + std::swap(low, high); + } + if ( detailType == GcFieldType::FIELD_INTEGER + || detailType == GcFieldType::FIELD_DATE + || detailType == GcFieldType::FIELD_TIME) { + similarLow = QString("%1").arg(static_cast(std::floor(low))); + similarHigh = QString("%1").arg(static_cast(std::ceil(high))); + } else { + similarLow = QString("%1").arg(low, 0, 'f', 2); + similarHigh = QString("%1").arg(high, 0, 'f', 2); + } + similar = true; + } else if (op == 5) { + opStr = ">="; + needQuotes = false; + } else if (op == 6) { + opStr = ">"; + needQuotes = false; + } + } + if (similar) { + filter += QString("%1>=%2 and %1<=%3").arg(item->data(0, Qt::UserRole + 1).toString()) + .arg(similarLow) + .arg(similarHigh); + } else { + filter += QString("%1%2%3%4%3").arg(item->data(0, Qt::UserRole + 1).toString()) + .arg(opStr) + .arg(needQuotes ? "\"" : "") + .arg(Utils::quoteEscape(item->data(0, Qt::UserRole + 2).toString())); + } + first = false; + } + } + } + return filter; +} + + +void +FilterSimilarDialog::addFields +() +{ + QFont bold = tree->font(); + bold.setWeight(QFont::Bold); + bold.setPointSize(bold.pointSize() * 1.1); + QFont light = tree->font(); + light.setWeight(QFont::Thin); + + QTreeWidgetItem* sectionFields = new QTreeWidgetItem(tree); + sectionFields->setData(0, Qt::DisplayRole, tr("Fields")); + sectionFields->setData(0, Qt::FontRole, bold); + sectionFields->setFlags(sectionFields->flags() & ~Qt::ItemIsSelectable); + sectionFields->setExpanded(true); + sectionFields->setFirstColumnSpanned(true); + + QList fieldsDefs = GlobalContext::context()->rideMetadata->getFields(); + for (const FieldDefinition &fieldDef : fieldsDefs) { + QString value = rideItem->getText(fieldDef.name, ""); + if (rideItem->hasText(fieldDef.name) && ! value.isEmpty()) { + QString searchKey = fieldDef.name; + searchKey.replace(' ', '_'); + QString displayValue = value; + if (fieldDef.isTextField()) { + displayValue.remove('\n').remove('\r'); + } else if (fieldDef.type == GcFieldType::FIELD_DATE) { + QLocale locale; + QDate date(1900, 1, 1); + displayValue = locale.toString(date.addDays(value.toInt()), QLocale::ShortFormat); + } else if (fieldDef.type == GcFieldType::FIELD_TIME) { + QLocale locale; + QTime time(0, 0, 0); + displayValue = locale.toString(time.addSecs(value.toInt()), QLocale::ShortFormat); + } + QTreeWidgetItem* item = new QTreeWidgetItem(sectionFields); + item->setData(0, Qt::DisplayRole, fieldDef.name); + item->setData(0, Qt::UserRole + 1, searchKey); + item->setData(0, Qt::UserRole + 2, value); + item->setData(1, Qt::DisplayRole, 0); + item->setData(1, Qt::FontRole, light); + item->setData(1, Qt::UserRole + 1, fieldDef.isTextField() ? 0 : 1); + item->setData(1, Qt::UserRole + 2, QVariant::fromValue(static_cast(fieldDef.type))); + item->setData(2, Qt::DisplayRole, displayValue); + item->setFlags(item->flags() | Qt::ItemIsSelectable | Qt::ItemIsEditable); + } + } +} + + +void +FilterSimilarDialog::addMetrics +() +{ + QFont bold = tree->font(); + bold.setWeight(QFont::Bold); + bold.setPointSize(bold.pointSize() * 1.1); + QFont light = tree->font(); + light.setWeight(QFont::Thin); + + QTreeWidgetItem* sectionMetrics = new QTreeWidgetItem(tree); + sectionMetrics->setData(0, Qt::DisplayRole, tr("Metrics")); + sectionMetrics->setData(0, Qt::FontRole, bold); + sectionMetrics->setFlags(sectionMetrics->flags() & ~Qt::ItemIsSelectable); + sectionMetrics->setExpanded(true); + sectionMetrics->setFirstColumnSpanned(true); + + bool useMetric = GlobalContext::context()->useMetricUnits; + const RideMetricFactory &factory = RideMetricFactory::instance(); + for (const QString &metricSymbol : factory.allMetrics()) { + if (metricSymbol.startsWith("compatibility_")) { + continue; + } + double value = const_cast(rideItem)->getForSymbol(metricSymbol, useMetric); + QString displayValue = const_cast(rideItem)->getStringForSymbol(metricSymbol, useMetric); + RideMetric const *rm = factory.rideMetric(metricSymbol); + QString displayKey = rm->name(); + QString searchKey = displayKey; + if (searchKey.endsWith("™")) { + searchKey.chop(7); + searchKey = searchKey.trimmed(); + } + searchKey.replace(' ', '_'); + QTreeWidgetItem* item = new QTreeWidgetItem(sectionMetrics); + item->setData(0, Qt::DisplayRole, Utils::unprotect(displayKey)); + item->setData(0, Qt::UserRole + 1, searchKey); + item->setData(0, Qt::UserRole + 2, value); + item->setData(1, Qt::DisplayRole, 0); + item->setData(1, Qt::FontRole, light); + item->setData(1, Qt::UserRole + 1, 1); + if (rm->isTime()) { + item->setData(1, Qt::UserRole + 2, 6); + } else if (rm->isDate()) { + item->setData(1, Qt::UserRole + 2, 5); + } else { + item->setData(1, Qt::UserRole + 2, 4); + } + item->setData(2, Qt::DisplayRole, displayValue); + item->setFlags(item->flags() | Qt::ItemIsSelectable | Qt::ItemIsEditable); + } +} + + +void +FilterSimilarDialog::updateFilterTree +() +{ + QString f = filterTreeEdit->text().trimmed(); + bool empty = f.isEmpty(); + bool hideZeroes = hideZeroed->isChecked(); + bool isHiddenZero = false; + for (int i = 0; i < tree->topLevelItemCount(); ++i) { + QTreeWidgetItem *section = tree->topLevelItem(i); + for (int j = 0; j < section->childCount(); ++j) { + QTreeWidgetItem* item = section->child(j); + + if (hideZeroes) { + int type = item->data(1, Qt::UserRole + 1).toInt(); // type: 0 - string, 1 - number + QVariant value = item->data(0, Qt::UserRole + 2); // raw value + if (type == 0) { + isHiddenZero = value.toString().isEmpty(); + } else { + isHiddenZero = qFuzzyIsNull(value.toDouble()); + } + } + + if (isHiddenZero) { + item->setHidden(true); + } else if ( empty + || item->data(1, Qt::DisplayRole).toInt() != 0 + || item->text(0).contains(f, Qt::CaseInsensitive)) { + item->setHidden(false); + } else { + item->setHidden(true); + } + } + section->setHidden(false); + } +} diff --git a/src/Gui/FilterSimilarDialog.h b/src/Gui/FilterSimilarDialog.h new file mode 100644 index 000000000..ce430445f --- /dev/null +++ b/src/Gui/FilterSimilarDialog.h @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Joachim Kohlhammer (joachim.kohlhammer@gmx.de) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef _GC_FilterSimilarDialog_h +#define _GC_FilterSimilarDialog_h 1 + +#include +#include + +#include "Context.h" +#include "RideItem.h" + + +class FilterSimilarDialog : public QDialog +{ + Q_OBJECT + + public: + FilterSimilarDialog(Context *context, RideItem const * const rideItem, QWidget *parent = nullptr); + + protected: + bool eventFilter(QObject *obj, QEvent *event) override; + + private: + Context *context; + RideItem const * const rideItem; + QCheckBox *hideZeroed; + QLineEdit *filterTreeEdit; + QTreeWidget *tree; + QTextEdit *preview; + + QString buildFilter() const; + void addFields(); + void addMetrics(); + + private slots: + void updateFilterTree(); +}; + +#endif diff --git a/src/Gui/MainWindow.cpp b/src/Gui/MainWindow.cpp index 1664a624e..6fcaa8517 100644 --- a/src/Gui/MainWindow.cpp +++ b/src/Gui/MainWindow.cpp @@ -1133,6 +1133,20 @@ MainWindow::~MainWindow() void MainWindow::setFilter(QStringList f) { currentAthleteTab->context->setFilter(f); } void MainWindow::clearFilter() { currentAthleteTab->context->clearFilter(); } +void +MainWindow::fillinFilter(const QString &filterText) +{ + searchBox->setMode(SearchBox::Filter); + searchBox->setText(filterText); +} + +void +MainWindow::fillinSearch(const QString &filterText) +{ + searchBox->setMode(SearchBox::Search); + searchBox->setText(filterText); +} + void MainWindow::aboutDialog() { diff --git a/src/Gui/MainWindow.h b/src/Gui/MainWindow.h index b9bfbafde..e3bf748f4 100644 --- a/src/Gui/MainWindow.h +++ b/src/Gui/MainWindow.h @@ -197,6 +197,8 @@ class MainWindow : public QMainWindow // Search / Filter void setFilter(QStringList); void clearFilter(); + void fillinFilter(const QString &filterText); + void fillinSearch(const QString &searchText); void selectAthlete(); void selectTrends(); diff --git a/src/Gui/StyledItemDelegates.cpp b/src/Gui/StyledItemDelegates.cpp index 4dc9dac30..222a2100d 100644 --- a/src/Gui/StyledItemDelegates.cpp +++ b/src/Gui/StyledItemDelegates.cpp @@ -248,6 +248,32 @@ ComboBoxDelegate::addItems } +void +ComboBoxDelegate::addItemsForType +(int type, const QStringList &texts) +{ + + textsForType[type] = texts; + fillSizeHint(); +} + + +void +ComboBoxDelegate::setRoleForType +(int role) +{ + _roleForType = role; +} + + +int +ComboBoxDelegate::roleForType +() const +{ + return _roleForType; +} + + void ComboBoxDelegate::commitAndCloseEditor () @@ -266,7 +292,22 @@ ComboBoxDelegate::createEditor Q_UNUSED(index) QComboBox *combobox = new QComboBox(parent); - combobox->addItems(texts); + + bool filled = false; + if (_roleForType != -1) { + QVariant typeVar = index.data(_roleForType); + if (typeVar.isValid() && ! typeVar.isNull() && typeVar.canConvert()) { + int type = typeVar.toInt(); + if (textsForType.contains(type)) { + combobox->addItems(textsForType[type]); + filled = true; + } + } + } + + if (! filled) { + combobox->addItems(texts); + } connect(combobox, SIGNAL(activated(int)), this, SLOT(commitAndCloseEditor())); @@ -295,17 +336,28 @@ ComboBoxDelegate::setModelData } -QString -ComboBoxDelegate::displayText -(const QVariant &value, const QLocale &locale) const +void +ComboBoxDelegate::initStyleOption +(QStyleOptionViewItem *option, const QModelIndex &index) const { - Q_UNUSED(locale); - - int index = value.toInt(); - if (index >= 0 && index < texts.length()) { - return texts[index]; + QStyledItemDelegate::initStyleOption(option, index); + int idx = index.data(Qt::DisplayRole).toInt(); + if (_roleForType != -1) { + QVariant typeVar = index.data(_roleForType); + if (typeVar.isValid() && ! typeVar.isNull() && typeVar.canConvert()) { + int type = typeVar.toInt(); + if (textsForType.contains(type) && idx >= 0 && idx < textsForType[type].length()) { + option->text = textsForType[type][idx]; + } else { + option->text = QString("INDEX OUT OF RANGE: %1 with %2 texts for type %3").arg(idx).arg(textsForType[type].length(), type); + } + } else { + option->text = ""; + } + } else if (idx >= 0 && idx < texts.length()) { + option->text = texts[idx]; } else { - return QString("INDEX OUT OF RANGE: %1 with %2 texts").arg(index).arg(texts.length()); + option->text = QString("INDEX OUT OF RANGE: %1 with %2 texts").arg(idx).arg(texts.length()); } } @@ -327,6 +379,9 @@ ComboBoxDelegate::fillSizeHint { QComboBox widget; widget.addItems(texts); + for (const QStringList &list : textsForType) { + widget.addItems(list); + } _sizeHint = widget.sizeHint(); } @@ -815,8 +870,9 @@ QWidget* ListEditDelegate::createEditor ([[maybe_unused]] QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { + Q_UNUSED(parent) Q_UNUSED(option) - Q_UNUSED(index) + emit requestListEdit(index); return nullptr; } diff --git a/src/Gui/StyledItemDelegates.h b/src/Gui/StyledItemDelegates.h index 0cbe05f35..bc4f8588a 100644 --- a/src/Gui/StyledItemDelegates.h +++ b/src/Gui/StyledItemDelegates.h @@ -118,19 +118,26 @@ class ComboBoxDelegate: public QStyledItemDelegate public: ComboBoxDelegate(QObject *parent = nullptr); - virtual QWidget* createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - virtual void setEditorData(QWidget *editor, const QModelIndex &index) const override; - virtual void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; - virtual QString displayText(const QVariant &value, const QLocale &locale) const override; - virtual QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QWidget* createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + void setEditorData(QWidget *editor, const QModelIndex &index) const override; + void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; void addItems(const QStringList &texts); + void addItemsForType(int type, const QStringList &texts); + void setRoleForType(int role); + int roleForType() const; + +protected: + void initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const override; private slots: void commitAndCloseEditor(); private: QStringList texts; + QMap textsForType; + int _roleForType = -1; QSize _sizeHint; void fillSizeHint(); diff --git a/src/src.pro b/src/src.pro index 4fe4f8e58..f8309c94e 100644 --- a/src/src.pro +++ b/src/src.pro @@ -650,7 +650,7 @@ HEADERS += Gui/AboutDialog.h Gui/AddIntervalDialog.h Gui/AnalysisSidebar.h Gui/C Gui/PerspectiveDialog.h Gui/SplashScreen.h Gui/StyledItemDelegates.h Gui/MetadataDialog.h Gui/ActionButtonBox.h \ Gui/MetricOverrideDialog.h Gui/RepeatScheduleWizard.h \ Gui/Calendar.h Gui/Agenda.h Gui/CalendarData.h Gui/CalendarItemDelegates.h \ - Gui/IconManager.h + Gui/IconManager.h Gui/FilterSimilarDialog.h # metrics and models HEADERS += Metrics/Banister.h Metrics/CPSolver.h Metrics/Estimator.h Metrics/ExtendedCriticalPower.h Metrics/HrZones.h Metrics/PaceZones.h \ @@ -763,7 +763,7 @@ SOURCES += Gui/AboutDialog.cpp Gui/AddIntervalDialog.cpp Gui/AnalysisSidebar.cpp Gui/PerspectiveDialog.cpp Gui/SplashScreen.cpp Gui/StyledItemDelegates.cpp Gui/MetadataDialog.cpp Gui/ActionButtonBox.cpp \ Gui/MetricOverrideDialog.cpp Gui/RepeatScheduleWizard.cpp \ Gui/Calendar.cpp Gui/Agenda.cpp Gui/CalendarData.cpp Gui/CalendarItemDelegates.cpp \ - Gui/IconManager.cpp + Gui/IconManager.cpp Gui/FilterSimilarDialog.cpp ## Models and Metrics SOURCES += Metrics/aBikeScore.cpp Metrics/aCoggan.cpp Metrics/AerobicDecoupling.cpp Metrics/Banister.cpp Metrics/BasicRideMetrics.cpp \ diff --git a/unittests/Core/utils/testUtils.cpp b/unittests/Core/utils/testUtils.cpp new file mode 100644 index 000000000..e2e06c8a1 --- /dev/null +++ b/unittests/Core/utils/testUtils.cpp @@ -0,0 +1,23 @@ +#include "Core/Utils.h" + +#include + + +class TestUtils: public QObject +{ + Q_OBJECT + +private slots: + void quoteEscapeTest() { + QCOMPARE(Utils::quoteEscape("abc"), QString("abc")); + QCOMPARE(Utils::quoteEscape("a\bc"), QString("a\bc")); + QCOMPARE(Utils::quoteEscape("a\\bc"), QString("a\\bc")); + QCOMPARE(Utils::quoteEscape("a\"bc"), QString("a\\\"bc")); + QCOMPARE(Utils::quoteEscape("a\\\"bc"), QString("a\\\"bc")); + QCOMPARE(Utils::quoteEscape("a\\\\\"bc"), QString("a\\\\\\\"bc")); + } +}; + + +QTEST_MAIN(TestUtils) +#include "testUtils.moc" diff --git a/unittests/Core/utils/utils.pro b/unittests/Core/utils/utils.pro new file mode 100644 index 000000000..143ddafb1 --- /dev/null +++ b/unittests/Core/utils/utils.pro @@ -0,0 +1,6 @@ +QT += testlib core widgets + +SOURCES = testUtils.cpp +GC_OBJS = Utils + +include(../../unittests.pri) diff --git a/unittests/unittests.pro b/unittests/unittests.pro index 9578b8fcb..a2faaa5f7 100644 --- a/unittests/unittests.pro +++ b/unittests/unittests.pro @@ -9,6 +9,7 @@ equals(GC_UNITTESTS, active) { Core/season \ Core/seasonParser \ Core/units \ + Core/utils \ Core/signalSafety \ Core/splineCrash \ Gui/calendarData