diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6f9ae0fc..49492995 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -234,6 +234,7 @@ set(PMP_DESKTOP_REMOTE_SOURCES desktop-remote/scrobblingauthenticationdialog.cpp desktop-remote/searchdata.cpp desktop-remote/searchdialog.cpp + desktop-remote/trackfilterwidgets.cpp desktop-remote/trackinfodialog.cpp desktop-remote/trackjudge.cpp desktop-remote/trackprogresswidget.cpp @@ -267,6 +268,7 @@ set(PMP_DESKTOP_REMOTE_HEADERS desktop-remote/scrobblingauthenticationdialog.h desktop-remote/searchdata.h desktop-remote/searchdialog.h + desktop-remote/trackfilterwidgets.h desktop-remote/trackinfodialog.h desktop-remote/trackprogresswidget.h desktop-remote/useraccountcreationwidget.h diff --git a/src/common/commonmetatypes.cpp b/src/common/commonmetatypes.cpp index 0e3f97b3..392beb0c 100644 --- a/src/common/commonmetatypes.cpp +++ b/src/common/commonmetatypes.cpp @@ -45,11 +45,11 @@ namespace PMP CommonMetatypesInit() { qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); - qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); diff --git a/src/common/trackcriteria.cpp b/src/common/trackcriteria.cpp index f21f898c..983d8094 100644 --- a/src/common/trackcriteria.cpp +++ b/src/common/trackcriteria.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2023-2025, Kevin André + Copyright (C) 2023-2026, Kevin André This file is part of PMP (Party Music Player). @@ -23,193 +23,17 @@ namespace PMP { - namespace - { - std::unique_ptr createLengthLessThanCriterium( - int lengthMinutesCeiling) - { - return std::make_unique( - ComparisonOperator::LessThan, lengthMinutesCeiling); - } - - std::unique_ptr createLengthAtLeastCriterium( - int minimumLengthMinutes) - { - return std::make_unique( - ComparisonOperator::GreaterThanOrEqual, minimumLengthMinutes); - } - - std::unique_ptr createScoreLessThanCriterium(short scoreCeiling) - { - return std::make_unique( - ComparisonOperator::LessThan, scoreCeiling); - } - - std::unique_ptr createScoreAtLeastCriterium(short minimumScore) - { - return std::make_unique( - ComparisonOperator::GreaterThanOrEqual, minimumScore); - } - - std::unique_ptr createNotRecentlyHeardCriterium( - CompositeDuration duration) - { - return std::make_unique( - duration, /* isInverted: */ true); - } - } - - std::unique_ptr convertToTrackCriterium( - PredefinedTrackCriterium criterium) - { - switch (criterium) - { - case PredefinedTrackCriterium::AllTracks: - return ConstantTrackCriterium::allTracksMatch(); - - case PredefinedTrackCriterium::NoTracks: - return ConstantTrackCriterium::noTracksMatch(); - - case PredefinedTrackCriterium::NeverHeard: - return TrackLastHeardPresenceCriterium::lastHeardMustBeAbsent(); - - case PredefinedTrackCriterium::NotHeardInLast5Years: - return createNotRecentlyHeardCriterium(CompositeDuration { .years = 5 }); - - case PredefinedTrackCriterium::NotHeardInLast3Years: - return createNotRecentlyHeardCriterium(CompositeDuration { .years = 3 }); - - case PredefinedTrackCriterium::NotHeardInLast2Years: - return createNotRecentlyHeardCriterium(CompositeDuration { .years = 2 }); - - case PredefinedTrackCriterium::NotHeardInLastYear: - return createNotRecentlyHeardCriterium(CompositeDuration { .years = 1 }); - - case PredefinedTrackCriterium::NotHeardInLast180Days: - return createNotRecentlyHeardCriterium(CompositeDuration { .days = 180 }); - - case PredefinedTrackCriterium::NotHeardInLast90Days: - return createNotRecentlyHeardCriterium(CompositeDuration { .days = 90 }); - - case PredefinedTrackCriterium::NotHeardInLast30Days: - return createNotRecentlyHeardCriterium(CompositeDuration { .days = 30 }); - - case PredefinedTrackCriterium::NotHeardInLast10Days: - return createNotRecentlyHeardCriterium(CompositeDuration { .days = 10 }); - - case PredefinedTrackCriterium::HeardAtLeastOnce: - return TrackLastHeardPresenceCriterium::lastHeardMustBePresent(); - - case PredefinedTrackCriterium::WithoutScore: - return TrackScorePresenceCriterium::scoreMustBeAbsent(); - - case PredefinedTrackCriterium::WithScore: - return TrackScorePresenceCriterium::scoreMustBePresent(); - - case PredefinedTrackCriterium::ScoreLessThan30: - return createScoreLessThanCriterium(30); - - case PredefinedTrackCriterium::ScoreLessThan50: - return createScoreLessThanCriterium(50); - - case PredefinedTrackCriterium::ScoreAtLeast80: - return createScoreAtLeastCriterium(80); - - case PredefinedTrackCriterium::ScoreAtLeast85: - return createScoreAtLeastCriterium(85); - - case PredefinedTrackCriterium::ScoreAtLeast90: - return createScoreAtLeastCriterium(90); - - case PredefinedTrackCriterium::ScoreAtLeast95: - return createScoreAtLeastCriterium(95); - - case PredefinedTrackCriterium::LengthLessThanOneMinute: - return createLengthLessThanCriterium(1); - - case PredefinedTrackCriterium::LengthAtLeastOneMinute: - return createLengthAtLeastCriterium(1); - - case PredefinedTrackCriterium::LengthLessThanTwoMinutes: - return createLengthLessThanCriterium(2); - - case PredefinedTrackCriterium::LengthAtLeastTwoMinutes: - return createLengthAtLeastCriterium(2); - - case PredefinedTrackCriterium::LengthLessThanThreeMinutes: - return createLengthLessThanCriterium(3); - - case PredefinedTrackCriterium::LengthAtLeastThreeMinutes: - return createLengthAtLeastCriterium(3); - - case PredefinedTrackCriterium::LengthLessThanFourMinutes: - return createLengthLessThanCriterium(4); - - case PredefinedTrackCriterium::LengthAtLeastFourMinutes: - return createLengthAtLeastCriterium(4); - - case PredefinedTrackCriterium::LengthLessThanFiveMinutes: - return createLengthLessThanCriterium(5); - - case PredefinedTrackCriterium::LengthAtLeastFiveMinutes: - return createLengthAtLeastCriterium(5); - - case PredefinedTrackCriterium::NotInTheQueue: - return TrackQueuePresenceCriterium::mustBeAbsentInQueue(); - - case PredefinedTrackCriterium::InTheQueue: - return TrackQueuePresenceCriterium::mustBePresentInQueue(); - - case PredefinedTrackCriterium::WithoutTitle: - return TrackMetaDataPresenceCriterium::mustBeAbsent(TrackMetaDataKind::Title); - - case PredefinedTrackCriterium::WithoutArtist: - return TrackMetaDataPresenceCriterium::mustBeAbsent( - TrackMetaDataKind::Artist); - - case PredefinedTrackCriterium::WithoutAlbum: - return TrackMetaDataPresenceCriterium::mustBeAbsent(TrackMetaDataKind::Album); - - case PredefinedTrackCriterium::NoLongerAvailable: - return TrackAvailabilityCriterium::mustBeUnavailable(); - } - - /* should be unreachable because we handled all enum values */ - Q_UNREACHABLE(); - } - - std::unique_ptr convertToTrackCriterium( - const QList& criteria) - { - if (criteria.isEmpty()) - return ConstantTrackCriterium::allTracksMatch(); - - if (criteria.size() == 1) - return convertToTrackCriterium(criteria.front()); - - auto composite = std::make_unique(); - - for (PredefinedTrackCriterium c : criteria) - { - composite->add(convertToTrackCriterium(c)); - } - - return composite; - } - - /* ============================================================================ */ - TrackLengthComparisonCriterium::TrackLengthComparisonCriterium() : _operator(ComparisonOperator::GreaterThanOrEqual), - _minutes(0) + _hours(0), _minutes(0), _seconds(0) { // } TrackLengthComparisonCriterium::TrackLengthComparisonCriterium( - ComparisonOperator comparisonOperator, int minutes) + ComparisonOperator comparisonOperator, int hours, int minutes, int seconds) : _operator(comparisonOperator), - _minutes(minutes) + _hours(hours), _minutes(minutes), _seconds(seconds) { // } diff --git a/src/common/trackcriteria.h b/src/common/trackcriteria.h index 15cbf220..0c9bb3e8 100644 --- a/src/common/trackcriteria.h +++ b/src/common/trackcriteria.h @@ -29,52 +29,7 @@ namespace PMP { - enum class PredefinedTrackCriterium - { - AllTracks = 0, - NoTracks, - NeverHeard, - NotHeardInLast5Years, - NotHeardInLast3Years, - NotHeardInLast2Years, - NotHeardInLastYear, - NotHeardInLast180Days, - NotHeardInLast90Days, - NotHeardInLast30Days, - NotHeardInLast10Days, - HeardAtLeastOnce, - WithoutScore, - WithScore, - ScoreLessThan30, - ScoreLessThan50, - ScoreAtLeast80, - ScoreAtLeast85, - ScoreAtLeast90, - ScoreAtLeast95, - LengthLessThanOneMinute, - LengthAtLeastOneMinute, - LengthLessThanTwoMinutes, - LengthAtLeastTwoMinutes, - LengthLessThanThreeMinutes, - LengthAtLeastThreeMinutes, - LengthLessThanFourMinutes, - LengthAtLeastFourMinutes, - LengthLessThanFiveMinutes, - LengthAtLeastFiveMinutes, - NotInTheQueue, - InTheQueue, - WithoutTitle, - WithoutArtist, - WithoutAlbum, - NoLongerAvailable, - }; - class TrackCriterium; - - std::unique_ptr convertToTrackCriterium(PredefinedTrackCriterium); - std::unique_ptr convertToTrackCriterium( - const QList&); - class TrackCriteriumVisitor; class TrackCriterium @@ -238,10 +193,18 @@ namespace PMP public: TrackLengthComparisonCriterium(); TrackLengthComparisonCriterium(ComparisonOperator comparisonOperator, - int minutes); + int hours, int minutes, int seconds); - void setLengthMinutes(int minutes) { _minutes = minutes; } - int lengthMinutes() const { return _minutes; } + void setLength(int hours, int minutes, int seconds) + { + _hours = hours; + _minutes = minutes; + _seconds = seconds; + } + + int hours() const { return _hours; } + int minutes() const { return _minutes; } + int seconds() const { return _seconds; } void setComparisonOperator(ComparisonOperator comparisonOperator) { @@ -267,12 +230,17 @@ namespace PMP { auto* o = dynamic_cast(&other); - return o && _operator == o->_operator && _minutes == o->_minutes; + return o && _operator == o->_operator + && _hours == o->hours() + && _minutes == o->minutes() + && _seconds == o->seconds(); } private: ComparisonOperator _operator; + int _hours; int _minutes; + int _seconds; }; class TrackScorePresenceCriterium final : public TrackCriterium @@ -412,6 +380,9 @@ namespace PMP { int years { 0 }; int days { 0 }; + int hours { 0 }; + + bool isZero() const { return years == 0 && days == 0 && hours == 0; } bool operator==(const CompositeDuration&) const = default; }; @@ -659,8 +630,120 @@ namespace PMP private: std::vector> _criteria; }; + + class TrackCriteriumFactory + { + public: + static std::unique_ptr lengthLessThanXMinutes(int minutes) + { + return std::make_unique( + ComparisonOperator::LessThan, 0, minutes, 0); + } + + static std::unique_ptr lengthAtLeastXMinutes(int minutes) + { + return std::make_unique( + ComparisonOperator::GreaterThanOrEqual, 0, minutes, 0); + } + + static std::unique_ptr scoreMustBePresent() + { + return TrackScorePresenceCriterium::scoreMustBePresent(); + } + + static std::unique_ptr scoreMustBeAbsent() + { + return TrackScorePresenceCriterium::scoreMustBeAbsent(); + } + + static std::unique_ptr scoreLessThanXPercent(short percent) + { + return std::make_unique( + ComparisonOperator::LessThan, percent); + } + + static std::unique_ptr scoreAtLeastXPercent(short percent) + { + return std::make_unique( + ComparisonOperator::GreaterThanOrEqual, percent); + } + + static std::unique_ptr notRecentlyHeard( + CompositeDuration duration) + { + return std::make_unique( + duration, /* isInverted: */ true); + } + + static std::unique_ptr neverHeard() + { + return TrackLastHeardPresenceCriterium::lastHeardMustBeAbsent(); + } + + static std::unique_ptr heardAtLeastOnce() + { + return TrackLastHeardPresenceCriterium::lastHeardMustBePresent(); + } + + static std::unique_ptr inTheQueue() + { + return TrackQueuePresenceCriterium::mustBePresentInQueue(); + } + + static std::unique_ptr notInTheQueue() + { + return TrackQueuePresenceCriterium::mustBeAbsentInQueue(); + } + + static std::unique_ptr available() + { + return TrackAvailabilityCriterium::mustBeAvailable(); + } + + static std::unique_ptr unavailable() + { + return TrackAvailabilityCriterium::mustBeUnavailable(); + } + + static std::unique_ptr withTitle() + { + return TrackMetaDataPresenceCriterium::mustBePresent( + TrackMetaDataKind::Title); + } + + static std::unique_ptr withoutTitle() + { + return TrackMetaDataPresenceCriterium::mustBeAbsent(TrackMetaDataKind::Title); + } + + static std::unique_ptr withArtist() + { + return TrackMetaDataPresenceCriterium::mustBePresent( + TrackMetaDataKind::Artist); + } + + static std::unique_ptr withoutArtist() + { + return TrackMetaDataPresenceCriterium::mustBeAbsent( + TrackMetaDataKind::Artist); + } + + static std::unique_ptr withAlbum() + { + return TrackMetaDataPresenceCriterium::mustBePresent( + TrackMetaDataKind::Album); + } + + static std::unique_ptr withoutAlbum() + { + return TrackMetaDataPresenceCriterium::mustBeAbsent(TrackMetaDataKind::Album); + } + + private: + TrackCriteriumFactory() = delete; + }; } -Q_DECLARE_METATYPE(PMP::PredefinedTrackCriterium) +Q_DECLARE_METATYPE(PMP::ComparisonOperator) #endif diff --git a/src/common/trackcriteriumevaluation.cpp b/src/common/trackcriteriumevaluation.cpp index 2941e114..c8a82e20 100644 --- a/src/common/trackcriteriumevaluation.cpp +++ b/src/common/trackcriteriumevaluation.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2025, Kevin André + Copyright (C) 2025-2026, Kevin André This file is part of PMP (Party Music Player). @@ -46,6 +46,14 @@ namespace PMP return s.value().trimmed().isEmpty(); } + + TriBool stringIsNotEmpty(Nullable s) + { + if (s.isNull()) + return TriBool::unknown; + + return !s.value().trimmed().isEmpty(); + } } TriBool TrackCriteriumEvaluator::evaluate(const TrackCriterium& criterium, @@ -79,7 +87,10 @@ namespace PMP } auto op = criterium.comparisonOperator(); - auto criteriumMs = criterium.lengthMinutes() * 60 * 1000; + auto criteriumMs = + criterium.hours() * 60 * 60 * 1000 + + criterium.minutes() * 60 * 1000 + + criterium.seconds() * 1000; _result = evaluateComparison(trackLengthMs.value(), op, criteriumMs); } @@ -150,7 +161,8 @@ namespace PMP auto startOfDuration = _context.currentDateTimeUtc() .addYears(-duration.years) - .addDays(-duration.days); + .addDays(-duration.days) + .addSecs(-duration.hours * 60 * 60); _result = criterium.isInverted() ? trackLastHeard.value() <= startOfDuration @@ -172,15 +184,24 @@ namespace PMP switch (criterium.metaDataKind()) { case TrackMetaDataKind::Title: - _result = stringIsEmpty(_context.title()); + _result = + criterium.presence() + ? stringIsNotEmpty(_context.title()) + : stringIsEmpty(_context.title()); return; case TrackMetaDataKind::Artist: - _result = stringIsEmpty(_context.artist()); + _result = + criterium.presence() + ? stringIsNotEmpty(_context.artist()) + : stringIsEmpty(_context.artist()); return; case TrackMetaDataKind::Album: - _result = stringIsEmpty(_context.album()); + _result = + criterium.presence() + ? stringIsNotEmpty(_context.album()) + : stringIsEmpty(_context.album()); return; } diff --git a/src/common/unicodechars.h b/src/common/unicodechars.h index 5b1d764d..b07b695a 100644 --- a/src/common/unicodechars.h +++ b/src/common/unicodechars.h @@ -1,5 +1,5 @@ /* - Copyright (C) 2016-2022, Kevin Andre + Copyright (C) 2016-2026, Kevin Andre This file is part of PMP (Party Music Player). @@ -50,6 +50,9 @@ namespace PMP /*! The "LESS-THAN OR EQUAL TO" symbol (U+2264) */ [[maybe_unused]] constexpr QChar lessThanOrEqual = QChar(0x2264); + /*! The inequality operator (U+2260) */ + [[maybe_unused]] constexpr QChar notEqual = QChar(0x2260); + /*! Pause symbol (U+23F8) */ [[maybe_unused]] constexpr QChar pauseSymbol = QChar(0x23F8); diff --git a/src/common/util.cpp b/src/common/util.cpp index bfb73b1e..90b849a8 100644 --- a/src/common/util.cpp +++ b/src/common/util.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2016-2024, Kevin Andre + Copyright (C) 2016-2026, Kevin André This file is part of PMP (Party Music Player). @@ -52,6 +52,51 @@ namespace PMP return result; } + void Util::normalizeDuration(int& hours, int& minutes, int& seconds) + { + int extraMinutes = seconds / 60; + int normalizedSeconds = seconds % 60; + + seconds = normalizedSeconds; + minutes += extraMinutes; + + int extraHours = minutes / 60; + int normalizedMinutes = minutes % 60; + + minutes = normalizedMinutes; + hours += extraHours; + } + + void Util::normalizeLongDuration(int& years, int& days, int& hours) + { + int extraDays = hours / 24; + int normalizedHours = hours % 24; + + hours = normalizedHours; + days += extraDays; + + const int daysInFourYears = 3 * 365 + 366; + + int fourYearInstances = days / daysInFourYears; + int daysLeft = days % daysInFourYears; + + days = daysLeft; + years += 4 * fourYearInstances; + + int extraYears = days / 365; + int normalizedDays = days % 365; + + // fix the edge case of 4 * 365 + if (extraYears == 4) + { + extraYears = 3; + normalizedDays += 365; + } + + days = normalizedDays; + years += extraYears; + } + QString Util::secondsToHoursMinuteSecondsText(qint32 totalSeconds) { if (totalSeconds < 0) { return "?"; } diff --git a/src/common/util.h b/src/common/util.h index 02bdde4d..20d805b6 100644 --- a/src/common/util.h +++ b/src/common/util.h @@ -1,5 +1,5 @@ /* - Copyright (C) 2016-2024, Kevin Andre + Copyright (C) 2016-2026, Kevin André This file is part of PMP (Party Music Player). @@ -79,6 +79,9 @@ namespace PMP public: static unsigned getRandomSeed(); + static void normalizeDuration(int& hours, int& minutes, int& seconds); + static void normalizeLongDuration(int& years, int& days, int& hours); + static QString secondsToHoursMinuteSecondsText(qint32 totalSeconds); static QString millisecondsToShortDisplayTimeText(qint64 milliseconds); static QString millisecondsToLongDisplayTimeText(qint64 milliseconds); diff --git a/src/desktop-remote/clickablelabel.cpp b/src/desktop-remote/clickablelabel.cpp index d71d304d..81575a6c 100644 --- a/src/desktop-remote/clickablelabel.cpp +++ b/src/desktop-remote/clickablelabel.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2021, Kevin Andre + Copyright (C) 2021-2026, Kevin André This file is part of PMP (Party Music Player). @@ -20,19 +20,29 @@ #include "clickablelabel.h" #include +#include namespace PMP { - ClickableLabel::ClickableLabel(QWidget* parent, Qt::WindowFlags f) : QLabel(parent, f) { - setCursor(QCursor(Qt::PointingHandCursor)); + setCursor(Qt::PointingHandCursor); } - ClickableLabel::~ClickableLabel() + void ClickableLabel::setClickable(bool clickable) { - // + if (_clickable == clickable) + return; + + _clickable = clickable; + + if (_clickable) + setCursor(Qt::PointingHandCursor); + else + unsetCursor(); + + Q_EMIT clickableChanged(); } ClickableLabel* ClickableLabel::replace(QLabel*& existingLabel) @@ -55,7 +65,9 @@ namespace PMP { Q_UNUSED(event) - Q_EMIT clicked(); - } + if (!_clickable) + return; + Q_EMIT clicked(event->pos()); + } } diff --git a/src/desktop-remote/clickablelabel.h b/src/desktop-remote/clickablelabel.h index ce845d13..5cbea610 100644 --- a/src/desktop-remote/clickablelabel.h +++ b/src/desktop-remote/clickablelabel.h @@ -1,5 +1,5 @@ /* - Copyright (C) 2021, Kevin Andre + Copyright (C) 2021-2026, Kevin André This file is part of PMP (Party Music Player). @@ -24,22 +24,28 @@ namespace PMP { - class ClickableLabel : public QLabel { Q_OBJECT public: explicit ClickableLabel(QWidget* parent = nullptr, Qt::WindowFlags f = Qt::WindowFlags()); - ~ClickableLabel(); + ~ClickableLabel() = default; + + bool clickable() const { return _clickable; } + void setClickable(bool clickable); static ClickableLabel* replace(QLabel*& existingLabel); Q_SIGNALS: - void clicked(); + void clickableChanged(); + void clicked(QPoint position); protected: void mousePressEvent(QMouseEvent* event) override; + + private: + bool _clickable { true }; }; } #endif diff --git a/src/desktop-remote/collectiontablemodel.cpp b/src/desktop-remote/collectiontablemodel.cpp index c7a8ba16..b4df9d1c 100644 --- a/src/desktop-remote/collectiontablemodel.cpp +++ b/src/desktop-remote/collectiontablemodel.cpp @@ -934,10 +934,19 @@ namespace PMP void FilteredCollectionTableModel::setTrackFilters(const TrackCriterium& criterium) { + qDebug() << "Music collection: got filters changed signal"; + bool changed = _filteringTrackJudge.setCriterium(criterium); if (changed) + { + qDebug() << "filters were really changed"; invalidateFilter(); + } + else + { + qDebug() << "filters did not really change"; + } } void FilteredCollectionTableModel::sort(int column, Qt::SortOrder order) diff --git a/src/desktop-remote/collectionwidget.cpp b/src/desktop-remote/collectionwidget.cpp index 68da1749..f0001770 100644 --- a/src/desktop-remote/collectionwidget.cpp +++ b/src/desktop-remote/collectionwidget.cpp @@ -20,8 +20,6 @@ #include "collectionwidget.h" #include "ui_collectionwidget.h" -#include "common/unicodechars.h" - #include "client/collectionwatcher.h" #include "client/queuecontroller.h" #include "client/serverinterface.h" @@ -29,6 +27,7 @@ #include "collectiontablemodel.h" #include "colors.h" #include "colorswitcher.h" +#include "trackfilterwidgets.h" #include "trackinfodialog.h" #include "waitingspinnerwidget.h" @@ -150,21 +149,21 @@ namespace PMP void CollectionWidget::onFiltersChanged() { - _collectionDisplayModel->setTrackFilters(_filtersListWidget->criterium()); + auto criterium = _filtersListWidget->createCriterium(); + _collectionDisplayModel->setTrackFilters(*criterium); } void CollectionWidget::onHighlightCriteriumChanged() { + auto criterium = _highlightingCriteriumWidget->createCriterium(); + bool nothingToHighlight = - _highlightingCriteriumPicker->criterium().equals( - *ConstantTrackCriterium::noTracksMatch() - ); + criterium->equals(*ConstantTrackCriterium::noTracksMatch()); _colorSwitcher->setVisible(!nothingToHighlight); + _ui->highlightTracksResetButton->setEnabled(!nothingToHighlight); - _collectionSourceModel->setHighlightCriterium( - _highlightingCriteriumPicker->criterium() - ); + _collectionSourceModel->setHighlightCriterium(*criterium); } void CollectionWidget::highlightColorIndexChanged() @@ -280,13 +279,14 @@ namespace PMP void CollectionWidget::initTrackHighlightingWidgets() { - _highlightingCriteriumPicker = - new FilterPickerWidget(PredefinedTrackCriterium::NoTracks, tr("(none)")); + _highlightingCriteriumWidget = new FilterLineWidget(); + _highlightingCriteriumWidget->setDeleteButtonVisible(false); + _highlightingCriteriumWidget->setResetButtonVisible(false); { auto layoutItem = this->layout()->replaceWidget(_ui->highlightTracksComboBox, - _highlightingCriteriumPicker); + _highlightingCriteriumWidget); delete layoutItem; /* we cannot delete the placeholder because of retranslateUi() so we hide it*/ @@ -294,7 +294,7 @@ namespace PMP } connect( - _highlightingCriteriumPicker, &FilterPickerWidget::criteriumChanged, + _highlightingCriteriumWidget, &FilterLineWidget::criteriumChanged, this, [this]() { onHighlightCriteriumChanged(); } ); @@ -315,10 +315,11 @@ namespace PMP auto* resetButton = _ui->highlightTracksResetButton; resetButton->setIcon(style()->standardIcon(QStyle::SP_LineEditClearButton)); resetButton->setToolTip(tr("Reset highlighting")); + resetButton->setEnabled(false); connect( resetButton, &QPushButton::clicked, - this, [this]() { _highlightingCriteriumPicker->clearCriterium(); } + this, [this]() { _highlightingCriteriumWidget->clearCriterium(); } ); updateColors(/* force: */ true); @@ -336,235 +337,4 @@ namespace PMP _colorSwitcher->setColors(colors.itemBackgroundHighlightColors); _usingColorsForDarkMode = darkMode; } - - // =============================================================== // - - FilterPickerWidget::FilterPickerWidget(PredefinedTrackCriterium criteriumForEmpty, - QString captionForEmpty) - : _predefinedCriterium(criteriumForEmpty), - _criterium(convertToTrackCriterium(criteriumForEmpty)) - { - _comboBox = new QComboBox(); - - QHBoxLayout* layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(_comboBox); - - fillTrackCriteriaComboBox(_comboBox, criteriumForEmpty, captionForEmpty); - - connect( - _comboBox, qOverload(&QComboBox::currentIndexChanged), - this, - [this]() - { - auto predefinedCriterium = - _comboBox->currentData().value(); - - if (_predefinedCriterium == predefinedCriterium) - return; - - _predefinedCriterium = predefinedCriterium; - _criterium = convertToTrackCriterium(predefinedCriterium); - Q_EMIT criteriumChanged(); - } - ); - } - - void FilterPickerWidget::clearCriterium() - { - _comboBox->setCurrentIndex(0); - } - - void FilterPickerWidget::fillTrackCriteriaComboBox(QComboBox* comboBox, - PredefinedTrackCriterium criteriumForEmpty, - QString captionForEmpty) - { - auto addItem = - [comboBox](QString text, PredefinedTrackCriterium mode) - { - text.replace(">=", UnicodeChars::greaterThanOrEqual) - .replace("<=", UnicodeChars::lessThanOrEqual); - - comboBox->addItem(text, QVariant::fromValue(mode)); - }; - - addItem(captionForEmpty, criteriumForEmpty); - - addItem(tr("never heard"), PredefinedTrackCriterium::NeverHeard); - addItem(tr("not heard in the last 5 years"), - PredefinedTrackCriterium::NotHeardInLast5Years); - addItem(tr("not heard in the last 3 years"), - PredefinedTrackCriterium::NotHeardInLast3Years); - addItem(tr("not heard in the last 2 years"), - PredefinedTrackCriterium::NotHeardInLast2Years); - addItem(tr("not heard in the last year"), - PredefinedTrackCriterium::NotHeardInLastYear); - addItem(tr("not heard in the last 180 days"), - PredefinedTrackCriterium::NotHeardInLast180Days); - addItem(tr("not heard in the last 90 days"), - PredefinedTrackCriterium::NotHeardInLast90Days); - addItem(tr("not heard in the last 30 days"), - PredefinedTrackCriterium::NotHeardInLast30Days); - addItem(tr("not heard in the last 10 days"), - PredefinedTrackCriterium::NotHeardInLast10Days); - addItem(tr("heard at least once"), PredefinedTrackCriterium::HeardAtLeastOnce); - - addItem(tr("without score"), PredefinedTrackCriterium::WithoutScore); - addItem(tr("with score"), PredefinedTrackCriterium::WithScore); - addItem(tr("score < 30"), PredefinedTrackCriterium::ScoreLessThan30); - addItem(tr("score < 50"), PredefinedTrackCriterium::ScoreLessThan50); - addItem(tr("score >= 80"), PredefinedTrackCriterium::ScoreAtLeast80); - addItem(tr("score >= 85"), PredefinedTrackCriterium::ScoreAtLeast85); - addItem(tr("score >= 90"), PredefinedTrackCriterium::ScoreAtLeast90); - addItem(tr("score >= 95"), PredefinedTrackCriterium::ScoreAtLeast95); - - addItem(tr("length < 1 min."), PredefinedTrackCriterium::LengthLessThanOneMinute); - addItem(tr("length >= 1 min."), PredefinedTrackCriterium::LengthAtLeastOneMinute); - addItem(tr("length < 2 min."), PredefinedTrackCriterium::LengthLessThanTwoMinutes); - addItem(tr("length >= 2 min."), PredefinedTrackCriterium::LengthAtLeastTwoMinutes); - addItem(tr("length < 3 min."), PredefinedTrackCriterium::LengthLessThanThreeMinutes); - addItem(tr("length >= 3 min."), PredefinedTrackCriterium::LengthAtLeastThreeMinutes); - addItem(tr("length < 4 min."), PredefinedTrackCriterium::LengthLessThanFourMinutes); - addItem(tr("length >= 4 min."), PredefinedTrackCriterium::LengthAtLeastFourMinutes); - addItem(tr("length < 5 min."), PredefinedTrackCriterium::LengthLessThanFiveMinutes); - addItem(tr("length >= 5 min."), PredefinedTrackCriterium::LengthAtLeastFiveMinutes); - - addItem(tr("not in the queue"), PredefinedTrackCriterium::NotInTheQueue); - addItem(tr("in the queue"), PredefinedTrackCriterium::InTheQueue); - - addItem(tr("without title"), PredefinedTrackCriterium::WithoutTitle); - addItem(tr("without artist"), PredefinedTrackCriterium::WithoutArtist); - addItem(tr("without album"), PredefinedTrackCriterium::WithoutAlbum); - - addItem(tr("no longer available"), PredefinedTrackCriterium::NoLongerAvailable); - - comboBox->setCurrentIndex(0); - } - - // =============================================================== // - - FilterLineWidget::FilterLineWidget() - { - _filterPicker = new FilterPickerWidget(PredefinedTrackCriterium::AllTracks, - tr("(empty)")); - _criterium = _filterPicker->criterium().clone(); - _deleteButton = new QPushButton(); - _resetButton = new QPushButton(); - - QHBoxLayout* layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->addWidget(_filterPicker, 1); - layout->addWidget(_deleteButton, 0); - layout->addWidget(_resetButton, 0); - - _deleteButton->setIcon(style()->standardIcon(QStyle::SP_DialogDiscardButton)); - _deleteButton->setToolTip(tr("Remove filter")); - - _resetButton->setIcon(style()->standardIcon(QStyle::SP_LineEditClearButton)); - _resetButton->setToolTip(tr("Clear filter")); - - connect( - _filterPicker, &FilterPickerWidget::criteriumChanged, - [this]() - { - _criterium = _filterPicker->criterium().clone(); - Q_EMIT criteriumChanged(); - } - ); - - connect( - _deleteButton, &QPushButton::clicked, - this, [this]() { Q_EMIT deleteClicked(); } - ); - - connect( - _resetButton, &QPushButton::clicked, - this, [this]() { _filterPicker->clearCriterium(); } - ); - } - - // =============================================================== // - - FiltersListWidget::FiltersListWidget() - : _criterium(ConstantTrackCriterium::allTracksMatch()) - { - _verticalLayout = new QVBoxLayout(this); - _verticalLayout->setContentsMargins(0, 0, 0, 0); - - addFilterLine(); - - auto* buttonsLayout = new QHBoxLayout(); - _verticalLayout->addLayout(buttonsLayout); - - _addButton = new QPushButton(tr("Add")); - _addButton->setToolTip(tr("Add filter")); - - buttonsLayout->addWidget(_addButton); - buttonsLayout->addStretch(); - - connect( - _addButton, &QPushButton::clicked, - this, - [this]() - { - addFilterLine(); - - // emit is not necessary because the new filter is "none" - //Q_EMIT criteriaChanged(); - } - ); - } - - void FiltersListWidget::addFilterLine() - { - auto* filter = new FilterLineWidget(); - - auto index = _filters.size(); - _verticalLayout->insertWidget(index, filter); - - _filters.append(filter); - rebuildCriterium(); - - connect( - filter, &FilterLineWidget::criteriumChanged, - this, - [this]() - { - rebuildCriterium(); - Q_EMIT criteriumChanged(); - } - ); - - connect( - filter, &FilterLineWidget::deleteClicked, - this, - [this, filter]() - { - auto index = _filters.indexOf(filter); - Q_ASSERT_X( - index >= 0, - "FiltersListWidget::addFilterLine", - "filter to be deleted not found" - ); - - _filters.removeAt(index); - filter->deleteLater(); - - rebuildCriterium(); - Q_EMIT criteriumChanged(); - } - ); - } - - void FiltersListWidget::rebuildCriterium() - { - auto compositeCriterium = std::make_unique(); - - for (auto const* filterLine : _filters) - { - compositeCriterium->add(filterLine->criterium().clone()); - } - - _criterium = std::move(compositeCriterium); - } } diff --git a/src/desktop-remote/collectionwidget.h b/src/desktop-remote/collectionwidget.h index dce33f92..5816f7a1 100644 --- a/src/desktop-remote/collectionwidget.h +++ b/src/desktop-remote/collectionwidget.h @@ -24,10 +24,7 @@ #include -QT_FORWARD_DECLARE_CLASS(QComboBox) QT_FORWARD_DECLARE_CLASS(QMenu) -QT_FORWARD_DECLARE_CLASS(QPushButton) -QT_FORWARD_DECLARE_CLASS(QVBoxLayout) namespace Ui { @@ -44,7 +41,7 @@ namespace PMP { class ColorSwitcher; class FilteredCollectionTableModel; - class FilterPickerWidget; + class FilterLineWidget; class FiltersListWidget; class SearchData; class SortedCollectionTableModel; @@ -81,7 +78,7 @@ namespace PMP Ui::CollectionWidget* _ui; WaitingSpinnerWidget* _spinner { nullptr }; FiltersListWidget* _filtersListWidget { nullptr }; - FilterPickerWidget* _highlightingCriteriumPicker { nullptr }; + FilterLineWidget* _highlightingCriteriumWidget { nullptr }; ColorSwitcher* _colorSwitcher; Client::ServerInterface* _serverInterface; UserForStatisticsDisplay* _userStatisticsDisplay; @@ -90,67 +87,5 @@ namespace PMP QMenu* _collectionContextMenu; bool _usingColorsForDarkMode { false }; }; - - class FilterPickerWidget : public QWidget - { - Q_OBJECT - public: - FilterPickerWidget(PredefinedTrackCriterium criteriumForEmpty, QString captionForEmpty); - - void clearCriterium(); - const TrackCriterium& criterium() const { return *_criterium; } - - Q_SIGNALS: - void criteriumChanged(); - - private: - void fillTrackCriteriaComboBox(QComboBox* comboBox, - PredefinedTrackCriterium criteriumForEmpty, - QString captionForEmpty); - - QComboBox* _comboBox; - PredefinedTrackCriterium _predefinedCriterium; - std::unique_ptr _criterium; - }; - - class FilterLineWidget : public QWidget - { - Q_OBJECT - public: - FilterLineWidget(); - - const TrackCriterium& criterium() const { return *_criterium; } - - Q_SIGNALS: - void criteriumChanged(); - void deleteClicked(); - - private: - FilterPickerWidget* _filterPicker; - QPushButton* _deleteButton; - QPushButton* _resetButton; - std::unique_ptr _criterium; - }; - - class FiltersListWidget : public QWidget - { - Q_OBJECT - public: - FiltersListWidget(); - - const TrackCriterium& criterium() const { return *_criterium; } - - Q_SIGNALS: - void criteriumChanged(); - - private: - void addFilterLine(); - void rebuildCriterium(); - - QPushButton* _addButton; - QVBoxLayout* _verticalLayout; - QList _filters; - std::unique_ptr _criterium; - }; } #endif diff --git a/src/desktop-remote/trackfilterwidgets.cpp b/src/desktop-remote/trackfilterwidgets.cpp new file mode 100644 index 00000000..9a130517 --- /dev/null +++ b/src/desktop-remote/trackfilterwidgets.cpp @@ -0,0 +1,1435 @@ +/* + Copyright (C) 2025-2026, Kevin André + + This file is part of PMP (Party Music Player). + + PMP 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 3 of the License, or (at your option) any later + version. + + PMP 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 PMP. If not, see . +*/ + +#include "trackfilterwidgets.h" + +#include "common/nullable.h" +#include "common/unicodechars.h" +#include "common/util.h" + +#include "clickablelabel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace PMP +{ + FilterLabelWidget::FilterLabelWidget(QWidget *parent) + : QWidget(parent) + { + _label = new ClickableLabel(); + _label->setClickable(false); + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(_label); + layout->addStretch(); + + connect( + _label, &ClickableLabel::clicked, + this, &FilterLabelWidget::editingRequested + ); + } + + void FilterLabelWidget::setCriterium(std::unique_ptr criterium) + { + _criterium = std::move(criterium); + + if (!_criterium) + { + _label->clear(); + _label->setClickable(false); + return; + } + + bool isEditable = FilterEditorFactory::isEditable(*_criterium); + _label->setClickable(isEditable); + + CriteriumCaptionGenerator visitor; + _criterium->accept(visitor); + auto caption = visitor.caption(); + + _label->setText(caption); + } + + std::unique_ptr FilterLabelWidget::createCriterium() const + { + return _criterium->clone(); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const ConstantTrackCriterium& criterium) + { + if (criterium.value()) + _caption = tr("match any track"); + else + _caption = tr("match no tracks"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackLengthPresenceCriterium& criterium) + { + if (criterium.presence()) + _caption = tr("length is known"); + else + _caption = tr("length is unknown"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackLengthComparisonCriterium& criterium) + { + auto opStr = toString(criterium.comparisonOperator()); + + auto hours = criterium.hours(); + auto minutes = criterium.minutes(); + auto seconds = criterium.seconds(); + + Util::normalizeDuration(hours, minutes, seconds); + + if (hours > 0 && minutes == 0 && seconds == 0) + { + _caption = tr("length %1 %2 hour(s)").arg(opStr).arg(hours); + } + else if (hours == 0 && minutes > 0 && seconds == 0) + { + _caption = tr("length %1 %2 minute(s)").arg(opStr).arg(minutes); + } + else if (hours == 0 && minutes == 0 && seconds > 0) + { + _caption = tr("length %1 %2 second(s)").arg(opStr).arg(seconds); + } + else if (hours == 0) + { + _caption = + tr("length %1 %2:%3") + .arg(opStr) + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0')); + } + else + { + _caption = + tr("length %1 %2:%3:%4") + .arg(opStr) + .arg(hours, 2, 10, QChar('0')) + .arg(minutes, 2, 10, QChar('0')) + .arg(seconds, 2, 10, QChar('0')); + } + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackScorePresenceCriterium& criterium) + { + if (criterium.presence()) + _caption = tr("has score"); + else + _caption = tr("no score"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackScoreComparisonCriterium& criterium) + { + auto caption = + tr("score %1 %2") + .arg(toString(criterium.comparisonOperator())) + .arg(criterium.score()); + + _caption = caption; + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackLastHeardPresenceCriterium& criterium) + { + if (criterium.presence()) + _caption = tr("heard at least once"); + else + _caption = tr("never heard"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackLastHeardRecentlyCriterium& criterium) + { + auto isInverted = criterium.isInverted(); + auto duration = criterium.duration(); + + Util::normalizeLongDuration(duration.years, duration.days, duration.hours); + + if (duration.isZero()) + { + if (isInverted) + { + _caption = tr("not heard in the last 0 seconds"); + } + else + { + _caption = tr("heard in the last 0 seconds"); + } + } + else if (duration.years > 0 && duration.days == 0 && duration.hours == 0) + { + if (isInverted) + { + _caption = tr("not heard in the last %1 year(s)").arg(duration.years); + } + else + { + _caption = tr("heard in the last %1 year(s)").arg(duration.years); + } + } + else if (duration.days > 0 && duration.years == 0 && duration.hours == 0) + { + if (isInverted) + { + _caption = tr("not heard in the last %1 day(s)").arg(duration.days); + } + else + { + _caption = tr("heard in the last %1 day(s)").arg(duration.days); + } + } + else if (duration.hours > 0 && duration.years == 0 && duration.days == 0) + { + if (isInverted) + { + _caption = tr("not heard in the last %1 hour(s)").arg(duration.hours); + } + else + { + _caption = tr("heard in the last %1 hour(s)").arg(duration.hours); + } + } + else if (duration.days > 0 && duration.hours > 0 && duration.years == 0) + { + if (isInverted) + { + _caption = tr("not heard in the last %1 day(s) %2 hour(s)") + .arg(duration.days) + .arg(duration.hours); + } + else + { + _caption = tr("heard in the last %1 day(s) %2 hour(s)") + .arg(duration.days) + .arg(duration.hours); + } + } + else if (duration.years > 0 && duration.days > 0 && duration.hours == 0) + { + if (isInverted) + { + _caption = tr("not heard in the last %1 year(s) %2 day(s)") + .arg(duration.years) + .arg(duration.days); + } + else + { + _caption = tr("heard in the last %1 year(s) %2 day(s)") + .arg(duration.years) + .arg(duration.days); + } + } + else // catch-all case + { + if (isInverted) + { + _caption = + tr("not heard in the last %1 year(s) %2 day(s) %3 hour(s)") + .arg(duration.years) + .arg(duration.days) + .arg(duration.hours); + } + else + { + _caption = + tr("heard in the last %1 year(s) %2 day(s) %3 hour(s)") + .arg(duration.years) + .arg(duration.days) + .arg(duration.hours); + } + } + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackQueuePresenceCriterium& criterium) + { + if (criterium.presence()) + _caption = tr("in the queue"); + else + _caption = tr("not in the queue"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackAvailabilityCriterium& criterium) + { + if (criterium.availability()) + _caption = tr("available"); + else + _caption = tr("unavailable"); + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const TrackMetaDataPresenceCriterium& criterium) + { + switch (criterium.metaDataKind()) + { + case TrackMetaDataKind::Title: + if (criterium.presence()) + _caption = tr("has title"); + else + _caption = tr("no title"); + break; + case TrackMetaDataKind::Artist: + if (criterium.presence()) + _caption = tr("has artist"); + else + _caption = tr("no artist"); + break; + case TrackMetaDataKind::Album: + if (criterium.presence()) + _caption = tr("has album"); + else + _caption = tr("no album"); + break; + } + } + + void FilterLabelWidget::CriteriumCaptionGenerator::visit( + const CompositeTrackCriterium& criterium) + { + // This function should not be called, because the composite criterium cannot be + // put inside a FilterLineWidget yet + Q_UNREACHABLE(); + } + + QString FilterLabelWidget::CriteriumCaptionGenerator::toString( + ComparisonOperator comparisonOperator) + { + QString operatorString; + switch (comparisonOperator) + { + case ComparisonOperator::Equal: + operatorString = "="; + break; + case ComparisonOperator::NotEqual: + operatorString = UnicodeChars::notEqual; + break; + case ComparisonOperator::LessThan: + operatorString = "<"; + break; + case ComparisonOperator::LessThanOrEqual: + operatorString = UnicodeChars::lessThanOrEqual; + break; + case ComparisonOperator::GreaterThan: + operatorString = ">"; + break; + case ComparisonOperator::GreaterThanOrEqual: + operatorString = UnicodeChars::greaterThanOrEqual; + break; + } + + return operatorString; + } + + // =============================================================== // + + namespace + { + void fillComboBoxWithComparisonOperators(QComboBox* comboBox) + { + comboBox->addItem("=", QVariant::fromValue(ComparisonOperator::Equal)); + + comboBox->addItem(UnicodeChars::notEqual, + QVariant::fromValue(ComparisonOperator::NotEqual)); + + comboBox->addItem("<", QVariant::fromValue(ComparisonOperator::LessThan)); + + comboBox->addItem(UnicodeChars::lessThanOrEqual, + QVariant::fromValue(ComparisonOperator::LessThanOrEqual)); + + comboBox->addItem(">", QVariant::fromValue(ComparisonOperator::GreaterThan)); + + comboBox->addItem(UnicodeChars::greaterThanOrEqual, + QVariant::fromValue(ComparisonOperator::GreaterThanOrEqual)); + } + + void selectValue(QComboBox* comboBox, ComparisonOperator op) + { + int index = -1; + switch (op) + { + case ComparisonOperator::Equal: + index = 0; + break; + case ComparisonOperator::NotEqual: + index = 1; + break; + case ComparisonOperator::LessThan: + index = 2; + break; + case ComparisonOperator::LessThanOrEqual: + index = 3; + break; + case ComparisonOperator::GreaterThan: + index = 4; + break; + case ComparisonOperator::GreaterThanOrEqual: + index = 5; + break; + } + + comboBox->setCurrentIndex(index); + } + + Nullable getSelectedComparisonOperator(QComboBox* comboBox) + { + if (comboBox->currentIndex() < 0) + return null; + + auto comparisonOperator = + comboBox->currentData().value(); + + return comparisonOperator; + } + + inline QString filtersMenuTr(const char* text) + { + return QCoreApplication::translate("TrackFilterMenu", text); + } + + void displayFiltersPopupMenu(QWidget* parent, QPoint globalPopupPosition, + std::function)> setFilter, + Nullable> emptyAction) + { + QMenu menu(parent); + + // Category: Score + QMenu* scoreMenu = menu.addMenu(filtersMenuTr("Score")); + + scoreMenu->addAction( + filtersMenuTr("Less than 30"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreLessThanXPercent(30)); } + ); + + scoreMenu->addAction( + filtersMenuTr("Less than 50"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreLessThanXPercent(50)); } + ); + + scoreMenu->addAction( + filtersMenuTr("At least 80"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreAtLeastXPercent(80)); } + ); + + scoreMenu->addAction( + filtersMenuTr("At least 85"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreAtLeastXPercent(85)); } + ); + + scoreMenu->addAction( + filtersMenuTr("At least 90"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreAtLeastXPercent(90)); } + ); + + scoreMenu->addSeparator(); + + scoreMenu->addAction( + filtersMenuTr("Has score"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreMustBePresent()); } + ); + + scoreMenu->addAction( + filtersMenuTr("No score"), + [setFilter]() { setFilter( + TrackCriteriumFactory::scoreMustBeAbsent()); } + ); + + // Category: Length + QMenu* lengthMenu = menu.addMenu(filtersMenuTr("Length")); + + lengthMenu->addAction( + filtersMenuTr("Less than 3 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthLessThanXMinutes(3)); } + ); + + lengthMenu->addAction( + filtersMenuTr("At least 3 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthAtLeastXMinutes(3)); } + ); + + lengthMenu->addAction( + filtersMenuTr("Less than 4 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthLessThanXMinutes(4)); } + ); + + lengthMenu->addAction( + filtersMenuTr("At least 4 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthAtLeastXMinutes(4)); } + ); + + lengthMenu->addAction( + filtersMenuTr("Less than 5 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthLessThanXMinutes(5)); } + ); + + lengthMenu->addAction( + filtersMenuTr("At least 5 minutes"), + [setFilter]() { setFilter( + TrackCriteriumFactory::lengthAtLeastXMinutes(5)); } + ); + + // Category: last heard + QMenu* lastHeardMenu = menu.addMenu(filtersMenuTr("Last heard")); + + lastHeardMenu->addAction( + filtersMenuTr("More than 2 years ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .years = 2 })); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("More than a year ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .years = 1 })); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("More than 90 days ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .days = 90 })); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("More than 7 days ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .days = 7 })); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("More than 8 hours ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .hours = 8 })); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("More than an hour ago"), + [setFilter]() { setFilter( + TrackCriteriumFactory::notRecentlyHeard({ .hours = 1 })); } + ); + + lastHeardMenu->addSeparator(); + + lastHeardMenu->addAction( + filtersMenuTr("Never"), + [setFilter]() { setFilter(TrackCriteriumFactory::neverHeard()); } + ); + + lastHeardMenu->addAction( + filtersMenuTr("At least once"), + [setFilter]() { setFilter(TrackCriteriumFactory::heardAtLeastOnce()); } + ); + + // Category: Metadata + QMenu* metadataMenu = menu.addMenu(filtersMenuTr("Metadata")); + + metadataMenu->addAction( + filtersMenuTr("Has title"), + [setFilter]() { setFilter(TrackCriteriumFactory::withTitle()); } + ); + + metadataMenu->addAction( + filtersMenuTr("Has artist"), + [setFilter]() { setFilter(TrackCriteriumFactory::withArtist()); } + ); + + metadataMenu->addAction( + filtersMenuTr("Has album"), + [setFilter]() { setFilter(TrackCriteriumFactory::withAlbum()); } + ); + + metadataMenu->addSeparator(); + + metadataMenu->addAction( + filtersMenuTr("No title"), + [setFilter]() { setFilter(TrackCriteriumFactory::withoutTitle()); } + ); + + metadataMenu->addAction( + filtersMenuTr("No artist"), + [setFilter]() { setFilter(TrackCriteriumFactory::withoutArtist()); } + ); + + metadataMenu->addAction( + filtersMenuTr("No album"), + [setFilter]() { setFilter(TrackCriteriumFactory::withoutAlbum()); } + ); + + // Category: Status + QMenu* statusMenu = menu.addMenu(filtersMenuTr("Status")); + + statusMenu->addAction( + filtersMenuTr("In queue"), + [setFilter]() { setFilter(TrackCriteriumFactory::inTheQueue()); } + ); + + statusMenu->addAction( + filtersMenuTr("Not in queue"), + [setFilter]() { setFilter(TrackCriteriumFactory::notInTheQueue()); } + ); + + statusMenu->addSeparator(); + + statusMenu->addAction( + filtersMenuTr("Available"), + [setFilter]() { setFilter(TrackCriteriumFactory::available()); } + ); + + statusMenu->addAction( + filtersMenuTr("Unavailable"), + [setFilter]() { setFilter(TrackCriteriumFactory::unavailable()); } + ); + + // The empty entry + if (emptyAction.hasValue()) + { + menu.addSeparator(); + menu.addAction( + filtersMenuTr("(empty)"), + [emptyAction]() { emptyAction.value()(); } + ); + } + + menu.exec(globalPopupPosition); + } + } + + // =============================================================== // + + ScoreComparisonEditorWidget::ScoreComparisonEditorWidget(QWidget* parent) + : FilterEditorWidget(parent) + { + auto* scoreLabel = new QLabel(tr("score")); + _operatorComboBox = new QComboBox(); + _scoreSpinBox = new QSpinBox(); + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(scoreLabel); + layout->addWidget(_operatorComboBox); + layout->addWidget(_scoreSpinBox); + layout->addStretch(); + + fillComboBoxWithComparisonOperators(_operatorComboBox); + + connect( + _operatorComboBox, &QComboBox::currentIndexChanged, + this, &ScoreComparisonEditorWidget::criteriumChanged + ); + + connect( + _scoreSpinBox, &QSpinBox::valueChanged, + this, &ScoreComparisonEditorWidget::criteriumChanged + ); + } + + void ScoreComparisonEditorWidget::setOperator(ComparisonOperator comparisonOperator) + { + selectValue(_operatorComboBox, comparisonOperator); + } + + void ScoreComparisonEditorWidget::setScore(int score) + { + _scoreSpinBox->setValue(score); + } + + std::unique_ptr ScoreComparisonEditorWidget::createCriterium() const + { + auto comparisonOperator = getSelectedComparisonOperator(_operatorComboBox); + + if (comparisonOperator == null) + return ConstantTrackCriterium::noTracksMatch(); + + auto score = _scoreSpinBox->value(); + + return std::make_unique(comparisonOperator.value(), + score); + } + + // =============================================================== // + + LastHeardEditorWidget::LastHeardEditorWidget(QWidget* parent) + : FilterEditorWidget(parent) + { + _inversionComboBox = new QComboBox(); + _yearsSpinBox = new QSpinBox(); + auto* yearsLabel = new QLabel(tr("years")); + _daysSpinBox = new QSpinBox(); + auto* daysLabel = new QLabel(tr("days")); + _hoursSpinBox = new QSpinBox(); + auto* hoursLabel = new QLabel(tr("hours")); + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(_inversionComboBox); + layout->addWidget(_yearsSpinBox); + layout->addWidget(yearsLabel); + layout->addWidget(_daysSpinBox); + layout->addWidget(daysLabel); + layout->addWidget(_hoursSpinBox); + layout->addWidget(hoursLabel); + layout->addStretch(); + + _inversionComboBox->addItem(tr("heard within")); + _inversionComboBox->addItem(tr("not heard within")); + + _daysSpinBox->setMaximum(999); + + connect( + _inversionComboBox, &QComboBox::currentIndexChanged, + this, &ScoreComparisonEditorWidget::criteriumChanged + ); + + connect( + _yearsSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + + connect( + _daysSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + + connect( + _hoursSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + } + + void LastHeardEditorWidget::setInverted(bool isInverted) + { + _inversionComboBox->setCurrentIndex(isInverted ? 1 : 0); + } + + void LastHeardEditorWidget::setPeriod(int years, int days, int hours) + { + Util::normalizeLongDuration(years, days, hours); + + Q_ASSERT_X(years >= 0 && years < 100, + "LastHeardEditorWidget::setPeriod", + "years out of range"); + + Q_ASSERT_X(days >= 0 && days < 1000, + "LastHeardEditorWidget::setPeriod", + "days out of range"); + + Q_ASSERT_X(hours >= 0 && hours < 100, + "LastHeardEditorWidget::setPeriod", + "hours out of range"); + + _suspendChangeSignal++; + + _yearsSpinBox->setValue(years); + _daysSpinBox->setValue(days); + _hoursSpinBox->setValue(hours); + + _suspendChangeSignal--; + + Q_EMIT criteriumChanged(); + } + + std::unique_ptr LastHeardEditorWidget::createCriterium() const + { + auto inversionIndex = _inversionComboBox->currentIndex(); + + if (inversionIndex < 0) + return ConstantTrackCriterium::noTracksMatch(); + + bool isInverted = inversionIndex == 1; + + auto duration = + CompositeDuration + { + .years = _yearsSpinBox->value(), + .days = _daysSpinBox->value(), + .hours = _hoursSpinBox->value() + }; + + return std::make_unique(duration, isInverted); + } + + // =============================================================== // + + LengthComparisonEditorWidget::LengthComparisonEditorWidget(QWidget* parent) + : FilterEditorWidget(parent) + { + auto* lengthLabel = new QLabel(tr("length")); + _operatorComboBox = new QComboBox(); + _hoursSpinBox = new QSpinBox(); + auto* hoursLabel = new QLabel(tr("h")); + _minutesSpinBox = new QSpinBox(); + auto* minutesLabel = new QLabel(tr("min.")); + _secondsSpinBox = new QSpinBox(); + auto* secondsLabel = new QLabel(tr("s")); + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(lengthLabel); + layout->addWidget(_operatorComboBox); + layout->addWidget(_hoursSpinBox); + layout->addWidget(hoursLabel); + layout->addWidget(_minutesSpinBox); + layout->addWidget(minutesLabel); + layout->addWidget(_secondsSpinBox); + layout->addWidget(secondsLabel); + layout->addStretch(); + + fillComboBoxWithComparisonOperators(_operatorComboBox); + + connect( + _operatorComboBox, &QComboBox::currentIndexChanged, + this, &ScoreComparisonEditorWidget::criteriumChanged + ); + + connect( + _hoursSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + + connect( + _minutesSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + + connect( + _secondsSpinBox, &QSpinBox::valueChanged, + this, [this]() { if (!_suspendChangeSignal) Q_EMIT criteriumChanged(); } + ); + } + + void LengthComparisonEditorWidget::setOperator(ComparisonOperator comparisonOperator) + { + selectValue(_operatorComboBox, comparisonOperator); + } + + void LengthComparisonEditorWidget::setLength(int hours, int minutes, int seconds) + { + Util::normalizeDuration(hours, minutes, seconds); + + Q_ASSERT_X(hours >= 0 && hours < 100, + "LengthComparisonEditorWidget::setLength", + "hours out of range"); + + Q_ASSERT_X(minutes >= 0 && minutes < 100, + "LengthComparisonEditorWidget::setLength", + "minutes out of range"); + + Q_ASSERT_X(seconds >= 0 && seconds < 100, + "LengthComparisonEditorWidget::setLength", + "seconds out of range"); + + _suspendChangeSignal++; + + _hoursSpinBox->setValue(hours); + _minutesSpinBox->setValue(minutes); + _secondsSpinBox->setValue(seconds); + + _suspendChangeSignal--; + + Q_EMIT criteriumChanged(); + } + + std::unique_ptr LengthComparisonEditorWidget::createCriterium() const + { + auto comparisonOperator = getSelectedComparisonOperator(_operatorComboBox); + + if (comparisonOperator == null) + return ConstantTrackCriterium::noTracksMatch(); + + int hours = _hoursSpinBox->value(); + int minutes = _minutesSpinBox->value(); + int seconds = _secondsSpinBox->value(); + + return std::make_unique( + comparisonOperator.value(), hours, minutes, seconds); + } + + // =============================================================== // + + bool FilterEditorFactory::isEditable(const TrackCriterium& criterium) + { + IsEditableVisitor visitor; + criterium.accept(visitor); + return visitor.isCriteriumEditable(); + } + + FilterEditorWidget* FilterEditorFactory::createFromCriterium(QWidget* parent, + const TrackCriterium& criterium) + { + EditorWidgetCreationVisitor visitor(parent); + criterium.accept(visitor); + return visitor.editorWidget(); + } + + void FilterEditorFactory::IsEditableVisitor::visit(const ConstantTrackCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackLengthPresenceCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackLengthComparisonCriterium&) + { + _isEditable = true; + } + + void FilterEditorFactory::IsEditableVisitor::visit(const TrackScorePresenceCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackScoreComparisonCriterium&) + { + _isEditable = true; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackLastHeardPresenceCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackLastHeardRecentlyCriterium&) + { + _isEditable = true; + } + + void FilterEditorFactory::IsEditableVisitor::visit(const TrackQueuePresenceCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit(const TrackAvailabilityCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit( + const TrackMetaDataPresenceCriterium&) + { + _isEditable = false; + } + + void FilterEditorFactory::IsEditableVisitor::visit(const CompositeTrackCriterium&) + { + _isEditable = false; + } + + FilterEditorFactory::EditorWidgetCreationVisitor::EditorWidgetCreationVisitor( + QWidget* parent) + : _parent(parent), + _editorWidget(nullptr) + { + // + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const ConstantTrackCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackLengthPresenceCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackLengthComparisonCriterium& lengthCriterium) + { + auto* editor = new LengthComparisonEditorWidget(_parent); + editor->setOperator(lengthCriterium.comparisonOperator()); + editor->setLength(lengthCriterium.hours(), + lengthCriterium.minutes(), + lengthCriterium.seconds()); + + _editorWidget = editor; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackScorePresenceCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackScoreComparisonCriterium& scoreComparisonCriterium) + { + auto* editor = new ScoreComparisonEditorWidget(_parent); + editor->setOperator(scoreComparisonCriterium.comparisonOperator()); + editor->setScore(scoreComparisonCriterium.score()); + + _editorWidget = editor; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackLastHeardPresenceCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackLastHeardRecentlyCriterium& criterium) + { + auto period = criterium.duration(); + + auto* editor = new LastHeardEditorWidget(_parent); + editor->setPeriod(period.years, period.days, period.hours); + editor->setInverted(criterium.isInverted()); + + _editorWidget = editor; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackQueuePresenceCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackAvailabilityCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const TrackMetaDataPresenceCriterium&) + { + _editorWidget = nullptr; + } + + void FilterEditorFactory::EditorWidgetCreationVisitor::visit( + const CompositeTrackCriterium&) + { + _editorWidget = nullptr; + } + + // =============================================================== // + + FilterLineWidget::FilterLineWidget() + { + init(); // default initialization to empty filter + } + + FilterLineWidget::FilterLineWidget(std::unique_ptr criterium) + { + init(); // default initialization to empty filter + + switchToLabel(_emptyFilterLabel, std::move(criterium)); + } + + FilterLineWidget::FilterLineWidget(FilterEditorWidget* editor) + { + init(); // default initialization to empty filter + + switchToEditor(_emptyFilterLabel, editor); + } + + void FilterLineWidget::init() + { + _emptyFilterLabel = new ClickableLabel(); + _labelWidget = nullptr; + _editorWidget = nullptr; + _editButton = new QPushButton(); + _doneButton = new QPushButton(); + _deleteButton = new QPushButton(); + _resetButton = new QPushButton(); + _isEmpty = true; + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(_emptyFilterLabel, 1); + layout->addWidget(_editButton, 0); + layout->addWidget(_doneButton, 0); + layout->addWidget(_deleteButton, 0); + layout->addWidget(_resetButton, 0); + + _emptyFilterLabel->setText(tr("(empty)")); + + _editButton->setIcon( + QIcon::fromTheme("document-edit", + style()->standardIcon(QStyle::SP_FileDialogDetailedView))); + _editButton->setToolTip(tr("Edit filter")); + + _doneButton->setIcon( + QIcon::fromTheme("dialog-ok-apply", + style()->standardIcon(QStyle::SP_DialogApplyButton))); + _doneButton->setToolTip(tr("Done editing")); + + _deleteVisible = true; + _deleteButton->setIcon(style()->standardIcon(QStyle::SP_DialogDiscardButton)); + _deleteButton->setToolTip(tr("Remove filter")); + + _resetVisible = true; + _resetButton->setIcon(style()->standardIcon(QStyle::SP_LineEditClearButton)); + _resetButton->setToolTip(tr("Clear filter")); + + _editButton->setVisible(false); + _doneButton->setVisible(false); + _resetButton->setEnabled(false); + + connect( + _emptyFilterLabel, &ClickableLabel::clicked, + this, &FilterLineWidget::onEmptyLabelClicked + ); + + connect( + _editButton, &QPushButton::clicked, + this, &FilterLineWidget::onEditClicked + ); + + connect( + _doneButton, &QPushButton::clicked, + this, [this]() { switchEditorToLabel(); } + ); + + connect( + _deleteButton, &QPushButton::clicked, + this, [this]() { Q_EMIT deleteClicked(); } + ); + + connect( + _resetButton, &QPushButton::clicked, + this, &FilterLineWidget::onResetClicked + ); + } + + std::unique_ptr FilterLineWidget::createCriterium() const + { + if (_labelWidget) + return _labelWidget->createCriterium(); + + if (_editorWidget) + return _editorWidget->createCriterium(); + + Q_ASSERT_X(_isEmpty, + "FilterLineWidget::createCriterium", + "should be empty at his point"); + + return ConstantTrackCriterium::noTracksMatch(); + } + + void FilterLineWidget::setDeleteButtonVisible(bool visible) + { + if (_deleteVisible == visible) + return; + + _deleteVisible = visible; + _deleteButton->setVisible(_deleteVisible); + } + + void FilterLineWidget::setResetButtonVisible(bool visible) + { + if (_resetVisible == visible) + return; + + _resetVisible = visible; + _resetButton->setVisible(_resetVisible); + } + + void FilterLineWidget::onEmptyLabelClicked(QPoint position) + { + QPoint globalPosition = _emptyFilterLabel->mapToGlobal(position); + + displayFiltersPopupMenu( + this, + globalPosition, + [this](auto criterium) + { + switchToLabel(_emptyFilterLabel, std::move(criterium)); + + Q_EMIT criteriumChanged(); + }, + null /* do not display 'empty' */ + ); + } + + void FilterLineWidget::onEditClicked() + { + Q_ASSERT_X(_labelWidget != nullptr, + "FilterLineWidget::onEditClicked", + "label widget must be present"); + + switchLabelToEditor(); + } + + void FilterLineWidget::onResetClicked() + { + clearCriterium(); + } + + void FilterLineWidget::clearCriterium() + { + if (_isEmpty) + return; + + if (_labelWidget) + { + switchToEmpty(_labelWidget); + + _labelWidget->deleteLater(); + _labelWidget = nullptr; + } + else if (_editorWidget) + { + switchToEmpty(_editorWidget); + + _editorWidget->deleteLater(); + _editorWidget = nullptr; + } + else + { + Q_UNREACHABLE(); + } + + Q_EMIT criteriumChanged(); + } + + void FilterLineWidget::switchToEmpty(QWidget* widgetToReplace) + { + layout()->replaceWidget(widgetToReplace, _emptyFilterLabel); + widgetToReplace->setVisible(false); + + _isEmpty = true; + + _emptyFilterLabel->setVisible(true); + _editButton->setVisible(false); + _doneButton->setVisible(false); + _resetButton->setEnabled(false); + } + + void FilterLineWidget::switchEditorToLabel() + { + Q_ASSERT_X(_editorWidget != nullptr, + "FilterLineWidget::switchEditorToLabel", + "editor not present!"); + + auto criterium = _editorWidget->createCriterium(); + + switchToLabel(_editorWidget, std::move(criterium)); + + _editorWidget->deleteLater(); + _editorWidget = nullptr; + } + + void FilterLineWidget::switchToLabel(QWidget* widgetToReplace, + std::unique_ptr criterium) + { + Q_ASSERT_X(_labelWidget == nullptr, + "FilterLineWidget::switchToLabel", + "label widget already present!"); + + bool isEditable = FilterEditorFactory::isEditable(*criterium); + + _labelWidget = new FilterLabelWidget(nullptr); + _labelWidget->setCriterium(std::move(criterium)); + + connect( + _labelWidget, &FilterLabelWidget::editingRequested, + this, [this] { switchLabelToEditor(); } + ); + + layout()->replaceWidget(widgetToReplace, _labelWidget); + widgetToReplace->setVisible(false); + + _isEmpty = false; + + _editButton->setVisible(isEditable); + _doneButton->setVisible(false); + _resetButton->setEnabled(true); + } + + void FilterLineWidget::switchLabelToEditor() + { + Q_ASSERT_X(_labelWidget != nullptr, + "FilterLineWidget::switchLabelToEditor", + "label widget not present!"); + + auto criterium = _labelWidget->createCriterium(); + + switchToEditor(_labelWidget, *criterium); + + _labelWidget->deleteLater(); + _labelWidget = nullptr; + } + + void FilterLineWidget::switchToEditor(QWidget* widgetToReplace, + const TrackCriterium& criterium) + { + switchToEditor(widgetToReplace, + FilterEditorFactory::createFromCriterium(nullptr, criterium)); + } + + void FilterLineWidget::switchToEditor(QWidget *widgetToReplace, + FilterEditorWidget* editor) + { + Q_ASSERT_X(_editorWidget == nullptr, + "FilterLineWidget::switchToEditor", + "editor already present!"); + + _editorWidget = editor; + + Q_ASSERT_X(_editorWidget != nullptr, + "FilterLineWidget::switchToEditor", + "failed to obtain editor for criterium"); + + connect( + _editorWidget, &FilterEditorWidget::criteriumChanged, + this, &FilterLineWidget::criteriumChanged + ); + + layout()->replaceWidget(widgetToReplace, _editorWidget); + widgetToReplace->setVisible(false); + + _isEmpty = false; + + _editButton->setVisible(false); + _doneButton->setVisible(true); + _resetButton->setEnabled(true); + } + + // =============================================================== // + + FiltersListWidget::FiltersListWidget() + { + _verticalLayout = new QVBoxLayout(this); + _verticalLayout->setContentsMargins(0, 0, 0, 0); + + auto* buttonsLayout = new QHBoxLayout(); + _verticalLayout->addLayout(buttonsLayout); + + _addMenuButton = new QPushButton(tr("Add…")); + _addMenuButton->setToolTip(tr("Add filter")); + + buttonsLayout->addWidget(_addMenuButton); + buttonsLayout->addStretch(); + + connect( + _addMenuButton, &QPushButton::clicked, + this, &FiltersListWidget::showAddMenu + ); + } + + std::unique_ptr FiltersListWidget::createCriterium() const + { + auto compositeCriterium = std::make_unique(); + + for (auto const* filterLine : _filters) + { + if (filterLine->isEmpty()) + continue; // skip empty filter + + compositeCriterium->add(filterLine->createCriterium()); + } + + return compositeCriterium; + } + + void FiltersListWidget::showAddMenu() + { + // Show menu below the button + QPoint pos = _addMenuButton->mapToGlobal(QPoint(0, _addMenuButton->height())); + + displayFiltersPopupMenu( + this, + pos, + [this](auto criterium) { addFilterLine(std::move(criterium)); }, + { [this]() { addFilterLine(new FilterLineWidget()); } } /* add empty filter */ + ); + } + + void FiltersListWidget::addFilterLine(std::unique_ptr criterium) + { + addFilterLine(new FilterLineWidget(std::move(criterium))); + } + + void FiltersListWidget::addFilterLine(FilterLineWidget* filterLine) + { + auto index = _filters.size(); + _verticalLayout->insertWidget(index, filterLine); + + _filters.append(filterLine); + + connect( + filterLine, &FilterLineWidget::criteriumChanged, + this, + [this]() + { + Q_EMIT criteriumChanged(); + } + ); + + connect( + filterLine, &FilterLineWidget::deleteClicked, + this, + [this, filterLine]() + { + auto index = _filters.indexOf(filterLine); + Q_ASSERT_X( + index >= 0, + "FiltersListWidget::addFilterLine", + "filter to be deleted not found" + ); + + _filters.removeAt(index); + filterLine->deleteLater(); + + Q_EMIT criteriumChanged(); + } + ); + + Q_EMIT criteriumChanged(); + } +} diff --git a/src/desktop-remote/trackfilterwidgets.h b/src/desktop-remote/trackfilterwidgets.h new file mode 100644 index 00000000..0c8620d6 --- /dev/null +++ b/src/desktop-remote/trackfilterwidgets.h @@ -0,0 +1,269 @@ +/* + Copyright (C) 2025-2026, Kevin André + + This file is part of PMP (Party Music Player). + + PMP 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 3 of the License, or (at your option) any later + version. + + PMP 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 PMP. If not, see . +*/ + +#ifndef PMP_TRACKFILTERWIDGETS_H +#define PMP_TRACKFILTERWIDGETS_H + +#include "common/trackcriteria.h" + +#include + +QT_FORWARD_DECLARE_CLASS(QComboBox) +QT_FORWARD_DECLARE_CLASS(QLabel) +QT_FORWARD_DECLARE_CLASS(QPushButton) +QT_FORWARD_DECLARE_CLASS(QSpinBox) +QT_FORWARD_DECLARE_CLASS(QVBoxLayout) + +namespace PMP +{ + class ClickableLabel; + + class FilterLabelWidget : public QWidget + { + Q_OBJECT + public: + explicit FilterLabelWidget(QWidget* parent); + + void setCriterium(std::unique_ptr criterium); + + std::unique_ptr createCriterium() const; + + Q_SIGNALS: + void editingRequested(); + + private: + class CriteriumCaptionGenerator : public TrackCriteriumVisitor + { + public: + QString caption() const { return _caption; } + + void visit(const ConstantTrackCriterium&) override; + void visit(const TrackLengthPresenceCriterium&) override; + void visit(const TrackLengthComparisonCriterium&) override; + void visit(const TrackScorePresenceCriterium&) override; + void visit(const TrackScoreComparisonCriterium&) override; + void visit(const TrackLastHeardPresenceCriterium&) override; + void visit(const TrackLastHeardRecentlyCriterium&) override; + void visit(const TrackQueuePresenceCriterium&) override; + void visit(const TrackAvailabilityCriterium&) override; + void visit(const TrackMetaDataPresenceCriterium&) override; + void visit(const CompositeTrackCriterium&) override; + + private: + QString toString(ComparisonOperator comparisonOperator); + + QString _caption; + }; + + std::unique_ptr _criterium; + ClickableLabel* _label; + }; + + class FilterEditorWidget : public QWidget + { + Q_OBJECT + public: + virtual ~FilterEditorWidget() = default; + + virtual std::unique_ptr createCriterium() const = 0; + + Q_SIGNALS: + void criteriumChanged(); + + protected: + explicit FilterEditorWidget(QWidget* parent = nullptr) : QWidget(parent) {} + }; + + class ScoreComparisonEditorWidget : public FilterEditorWidget + { + Q_OBJECT + public: + explicit ScoreComparisonEditorWidget(QWidget* parent); + void setOperator(ComparisonOperator comparisonOperator); + void setScore(int score); + + std::unique_ptr createCriterium() const override; + + private: + QComboBox* _operatorComboBox; + QSpinBox* _scoreSpinBox; + }; + + class LastHeardEditorWidget : public FilterEditorWidget + { + Q_OBJECT + public: + explicit LastHeardEditorWidget(QWidget* parent); + + void setInverted(bool isInverted); + void setPeriod(int years, int days, int hours); + + std::unique_ptr createCriterium() const override; + + private: + QComboBox* _inversionComboBox; + QSpinBox* _yearsSpinBox; + QSpinBox* _daysSpinBox; + QSpinBox* _hoursSpinBox; + quint8 _suspendChangeSignal { 0 }; + }; + + class LengthComparisonEditorWidget : public FilterEditorWidget + { + Q_OBJECT + public: + explicit LengthComparisonEditorWidget(QWidget* parent); + void setOperator(ComparisonOperator comparisonOperator); + void setLength(int hours, int minutes, int seconds); + + std::unique_ptr createCriterium() const override; + + private: + QComboBox* _operatorComboBox; + QSpinBox* _hoursSpinBox; + QSpinBox* _minutesSpinBox; + QSpinBox* _secondsSpinBox; + quint8 _suspendChangeSignal { 0 }; + }; + + class FilterEditorFactory + { + public: + static bool isEditable(TrackCriterium const& criterium); + static FilterEditorWidget* createFromCriterium(QWidget* parent, + TrackCriterium const& criterium); + + private: + class IsEditableVisitor final : public TrackCriteriumVisitor + { + public: + bool isCriteriumEditable() const { return _isEditable; } + + void visit(const ConstantTrackCriterium&) override; + void visit(const TrackLengthPresenceCriterium&) override; + void visit(const TrackLengthComparisonCriterium&) override; + void visit(const TrackScorePresenceCriterium&) override; + void visit(const TrackScoreComparisonCriterium&) override; + void visit(const TrackLastHeardPresenceCriterium&) override; + void visit(const TrackLastHeardRecentlyCriterium&) override; + void visit(const TrackQueuePresenceCriterium&) override; + void visit(const TrackAvailabilityCriterium&) override; + void visit(const TrackMetaDataPresenceCriterium&) override; + void visit(const CompositeTrackCriterium&) override; + + private: + bool _isEditable { false }; + }; + + class EditorWidgetCreationVisitor final : public TrackCriteriumVisitor + { + public: + explicit EditorWidgetCreationVisitor(QWidget* parent); + + FilterEditorWidget* editorWidget() const { return _editorWidget; } + + void visit(const ConstantTrackCriterium&) override; + void visit(const TrackLengthPresenceCriterium&) override; + void visit(const TrackLengthComparisonCriterium&) override; + void visit(const TrackScorePresenceCriterium&) override; + void visit(const TrackScoreComparisonCriterium&) override; + void visit(const TrackLastHeardPresenceCriterium&) override; + void visit(const TrackLastHeardRecentlyCriterium&) override; + void visit(const TrackQueuePresenceCriterium&) override; + void visit(const TrackAvailabilityCriterium&) override; + void visit(const TrackMetaDataPresenceCriterium&) override; + void visit(const CompositeTrackCriterium&) override; + + private: + QWidget* _parent; + FilterEditorWidget* _editorWidget; + }; + }; + + class FilterLineWidget : public QWidget + { + Q_OBJECT + public: + FilterLineWidget(); + FilterLineWidget(std::unique_ptr criterium); + FilterLineWidget(FilterEditorWidget* editor); + + void clearCriterium(); + bool isEmpty() const { return _isEmpty; } + + std::unique_ptr createCriterium() const; + + void setDeleteButtonVisible(bool visible); + void setResetButtonVisible(bool visible); + + Q_SIGNALS: + void criteriumChanged(); + void deleteClicked(); + + private Q_SLOTS: + void onEmptyLabelClicked(QPoint position); + void onEditClicked(); + void onResetClicked(); + + private: + void init(); + void switchToEmpty(QWidget* widgetToReplace); + void switchEditorToLabel(); + void switchToLabel(QWidget* widgetToReplace, + std::unique_ptr criterium); + void switchLabelToEditor(); + void switchToEditor(QWidget* widgetToReplace, TrackCriterium const& criterium); + void switchToEditor(QWidget* widgetToReplace, FilterEditorWidget* editor); + + ClickableLabel* _emptyFilterLabel; + FilterLabelWidget* _labelWidget; + FilterEditorWidget* _editorWidget; + QPushButton* _editButton; + QPushButton* _doneButton; + QPushButton* _deleteButton; + QPushButton* _resetButton; + bool _isEmpty; + bool _deleteVisible; + bool _resetVisible; + }; + + class FiltersListWidget : public QWidget + { + Q_OBJECT + public: + FiltersListWidget(); + + std::unique_ptr createCriterium() const; + + Q_SIGNALS: + void criteriumChanged(); + + private Q_SLOTS: + void showAddMenu(); + + private: + void addFilterLine(std::unique_ptr criterium); + void addFilterLine(FilterLineWidget* filterLine); + + QPushButton* _addMenuButton; + QVBoxLayout* _verticalLayout; + QList _filters; + }; +} +#endif diff --git a/tests/test_trackcriteriumsimplification.cpp b/tests/test_trackcriteriumsimplification.cpp index c6ac9b63..1e90fb92 100644 --- a/tests/test_trackcriteriumsimplification.cpp +++ b/tests/test_trackcriteriumsimplification.cpp @@ -106,7 +106,8 @@ void TestTrackCriteriumSimplification::compositeContainingOneNoTracksSimplifiedT input->add(createAllTracks()); input->add(TrackQueuePresenceCriterium::mustBeAbsentInQueue()); input->add(createNoTracks()); - input->add(ComparisonOperator::GreaterThanOrEqual, 3); + input->add(ComparisonOperator::GreaterThanOrEqual, + 0, 3, 0); auto simplified = TrackCriteriumSimplifier::simplify(*input); diff --git a/tests/test_util.cpp b/tests/test_util.cpp index 360f9008..178a797c 100644 --- a/tests/test_util.cpp +++ b/tests/test_util.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2018-2022, Kevin Andre + Copyright (C) 2018-2026, Kevin André This file is part of PMP (Party Music Player). @@ -227,6 +227,160 @@ void TestUtil::getRandomSeed() QVERIFY(seed3 != seed4); } +void TestUtil::normalizeDuration() +{ + int hours, minutes, seconds; + + { + hours = 0; minutes = 0; seconds = 0; + Util::normalizeDuration(hours, minutes, seconds); + + QCOMPARE(hours, 0); + QCOMPARE(minutes, 0); + QCOMPARE(seconds, 0); + } + + { + hours = 1; minutes = 30; seconds = 8; + Util::normalizeDuration(hours, minutes, seconds); + + QCOMPARE(hours, 1); + QCOMPARE(minutes, 30); + QCOMPARE(seconds, 8); + } + + { + hours = 1; minutes = 30; seconds = 60; + Util::normalizeDuration(hours, minutes, seconds); + + QCOMPARE(hours, 1); + QCOMPARE(minutes, 31); + QCOMPARE(seconds, 0); + } + + { + hours = 1; minutes = 70; seconds = 5; + Util::normalizeDuration(hours, minutes, seconds); + + QCOMPARE(hours, 2); + QCOMPARE(minutes, 10); + QCOMPARE(seconds, 5); + } + + { + hours = 1; minutes = 59; seconds = 137; + Util::normalizeDuration(hours, minutes, seconds); + + QCOMPARE(hours, 2); + QCOMPARE(minutes, 1); + QCOMPARE(seconds, 17); + } +} + +void TestUtil::normalizeLongDuration() +{ + int years, days, hours; + + { + years = 0; days = 0; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 0); + QCOMPARE(days, 0); + QCOMPARE(hours, 0); + } + + { + years = 0; days = 0; hours = 4; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 0); + QCOMPARE(days, 0); + QCOMPARE(hours, 4); + } + + { + years = 0; days = 1; hours = 30; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 0); + QCOMPARE(days, 2); + QCOMPARE(hours, 6); + } + + { + years = 0; days = 0; hours = 365 * 24 + 15; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 1); + QCOMPARE(days, 0); + QCOMPARE(hours, 15); + } + + { + years = 3; days = 0; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 3); + QCOMPARE(days, 0); + QCOMPARE(hours, 0); + } + + { + years = 0; days = 90; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 0); + QCOMPARE(days, 90); + QCOMPARE(hours, 0); + } + + { + years = 1; days = 7; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 1); + QCOMPARE(days, 7); + QCOMPARE(hours, 0); + } + + { + years = 2; days = 370; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 3); + QCOMPARE(days, 5); + QCOMPARE(hours, 0); + } + + { + years = 0; days = 999; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 2); + QCOMPARE(days, 269); + QCOMPARE(hours, 0); + } + + { + years = 0; days = 365 + 365 + 365 + 365; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 3); + QCOMPARE(days, 365); + QCOMPARE(hours, 0); + } + + { + years = 0; days = 365 + 365 + 365 + 366; hours = 0; + Util::normalizeLongDuration(years, days, hours); + + QCOMPARE(years, 4); + QCOMPARE(days, 0); + QCOMPARE(hours, 0); + } +} + void TestUtil::generateZeroedMemory() { auto a = Util::generateZeroedMemory(29); diff --git a/tests/test_util.h b/tests/test_util.h index ae582ab7..6ee2a097 100644 --- a/tests/test_util.h +++ b/tests/test_util.h @@ -1,5 +1,5 @@ /* - Copyright (C) 2018-2021, Kevin Andre + Copyright (C) 2018-2026, Kevin André This file is part of PMP (Party Music Player). @@ -28,6 +28,8 @@ class TestUtil : public QObject private Q_SLOTS: void getCopyrightLine(); void getRandomSeed(); + void normalizeDuration(); + void normalizeLongDuration(); void secondsToHoursMinuteSecondsText(); void millisecondsToShortDisplayTimeText(); void millisecondsToLongDisplayTimeText();