From cedb7d36c022e6c9bfdb011fd01fc3c610a32006 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 14 Nov 2025 21:09:42 -0600 Subject: [PATCH] Dates and Favorites --- common/params.cc | 4 + frogpilot/assets/model_manager.py | 3 + frogpilot/common/frogpilot_variables.py | 9 +- .../offroad/expandable_multi_option_dialog.cc | 756 +++++++++++++++--- .../offroad/expandable_multi_option_dialog.h | 56 +- frogpilot/ui/qt/offroad/model_settings.cc | 514 +++++++----- frogpilot/ui/qt/offroad/model_settings.h | 4 + 7 files changed, 1021 insertions(+), 325 deletions(-) diff --git a/common/params.cc b/common/params.cc index 1d45baff9..f3338e143 100644 --- a/common/params.cc +++ b/common/params.cc @@ -407,6 +407,10 @@ std::unordered_map keys = { {"ModelToDownload", CLEAR_ON_MANAGER_START}, {"ModelUI", PERSISTENT}, {"ModelVersions", PERSISTENT}, + {"ModelReleasedDates", PERSISTENT}, + {"CommunityFavorites", PERSISTENT}, + {"UserFavorites", PERSISTENT}, + {"SortModelsByDate", PERSISTENT}, {"NavigationUI", PERSISTENT}, {"NextMapSpeedLimit", CLEAR_ON_MANAGER_START}, {"NewLongAPI", PERSISTENT}, diff --git a/frogpilot/assets/model_manager.py b/frogpilot/assets/model_manager.py index 18429cbd1..63bf6a2f3 100644 --- a/frogpilot/assets/model_manager.py +++ b/frogpilot/assets/model_manager.py @@ -296,7 +296,10 @@ class ModelManager: params.put("AvailableModels", ",".join(self.available_models)) params.put("AvailableModelNames", ",".join([model["name"] for model in model_info])) params.put("AvailableModelSeries", ",".join(self.model_series)) + params.put("CommunityFavorites", ",".join([model["id"] for model in model_info if model.get("community_favorite", False)])) + params.put("ModelReleasedDates", ",".join([model.get("released", "2023-01-01") for model in model_info])) params.put("ModelVersions", ",".join(self.model_versions)) + params.put("CommunityFavorites", ",".join([model["id"] for model in model_info if model.get("community_favorite", False)])) params.put("AvailableModelSeries", ",".join(self.model_series)) print("Models list updated successfully") diff --git a/frogpilot/common/frogpilot_variables.py b/frogpilot/common/frogpilot_variables.py index c73ff5d47..8cf071973 100644 --- a/frogpilot/common/frogpilot_variables.py +++ b/frogpilot/common/frogpilot_variables.py @@ -148,6 +148,8 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [ ("AvailableModelNames", "", 1, ""), ("AvailableModelSeries", "", 1, ""), ("AvailableModels", "", 1, ""), + ("CommunityFavorites", "", 1, ""), + ("UserFavorites", "", 0, ""), ("BigMap", "0", 2, "0"), ("BlacklistedModels", "", 2, ""), ("BlindSpotMetrics", "1", 3, "0"), @@ -284,8 +286,10 @@ frogpilot_default_params: list[tuple[str, str | bytes, int, str]] = [ ("Model", DEFAULT_MODEL, 1, DEFAULT_MODEL), ("ModelDrivesAndScores", "", 2, ""), ("ModelRandomizer", "0", 2, "0"), + ("ModelReleasedDates", "", 1, ""), ("ModelUI", "1", 2, "0"), ("ModelVersions", "", 2, ""), + ("SortModelsByDate", "0", 2, "0"), ("NavigationUI", "1", 1, "0"), ("NavSettingLeftSide", "0", 0, "0"), ("NavSettingTime24h", "0", 0, "0"), @@ -828,8 +832,11 @@ class FrogPilotVariables: toggle.available_models = params.get("AvailableModels", encoding="utf-8") or "" toggle.available_model_names = params.get("AvailableModelNames", encoding="utf-8") or "" toggle.available_model_series = params.get("AvailableModelSeries", encoding="utf-8") or "" + toggle.community_favorites = params.get("CommunityFavorites", encoding="utf-8") or "" + toggle.model_released_dates = params.get("ModelReleasedDates", encoding="utf-8") or "" toggle.model_versions = params.get("ModelVersions", encoding="utf-8") or "" - toggle.available_model_series = params.get("AvailableModelSeries", encoding="utf-8") or "" + toggle.sort_models_by_date = params.get_bool("SortModelsByDate") if tuning_level >= level["SortModelsByDate"] else default.get_bool("SortModelsByDate") + toggle.user_favorites = params.get("UserFavorites", encoding="utf-8") or "" downloaded_models = [model for model in toggle.available_models.split(",") if any(MODELS_PATH.glob(f"{model}*"))] toggle.model_randomizer = downloaded_models and (params.get_bool("ModelRandomizer") if tuning_level >= level["ModelRandomizer"] else default.get_bool("ModelRandomizer")) if toggle.available_models and toggle.available_model_names and downloaded_models and toggle.model_versions: diff --git a/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.cc b/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.cc index e545045b7..25b5f32ce 100644 --- a/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.cc +++ b/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.cc @@ -1,19 +1,74 @@ #include "frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h" #include -#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include #include "selfdrive/ui/qt/widgets/scrollview.h" ExpandableMultiOptionDialog::ExpandableMultiOptionDialog(const QString &prompt_text, - const QMap &seriesToModels, - const QString ¤t, QWidget *parent) - : DialogBase(parent), seriesToModels(seriesToModels) { + const QMap &seriesToModels, + const QString ¤t, QWidget *parent, + const QStringList &userFavorites, + const QStringList &communityFavorites, + const QMap &modelReleasedDates, + const QMap &modelFileToNameMap, + const QString &initialSortMode) + : DialogBase(parent), seriesToModels(seriesToModels), currentSortMode(initialSortMode.isEmpty() ? QString("alphabetical") : initialSortMode), + userFavorites(userFavorites), communityFavorites(communityFavorites), modelReleasedDates(modelReleasedDates), + modelFileToNameMap(modelFileToNameMap), currentSelection(current) { + + baseSeriesToModels = seriesToModels; + + for (auto it = this->modelFileToNameMap.constBegin(); it != this->modelFileToNameMap.constEnd(); ++it) { + modelNameToFileMap.insert(it.value(), it.key()); + } + + for (auto it = seriesToModels.constBegin(); it != seriesToModels.constEnd(); ++it) { + const QStringList &models = it.value(); + for (const QString &modelName : models) { + if (modelName.isEmpty() || modelNameToFileMap.contains(modelName)) { + continue; + } + this->modelFileToNameMap.insert(modelName, modelName); + modelNameToFileMap.insert(modelName, modelName); + } + } + + currentSelectionKey = modelNameToFileMap.value(currentSelection); + if (!currentSelectionKey.isEmpty()) { + selectionKey = currentSelectionKey; + selection = this->modelFileToNameMap.value(currentSelectionKey, currentSelection); + currentSelection = selection; + } else { + selectionKey.clear(); + selection.clear(); + currentSelection.clear(); + } + + if (currentSortMode != "alphabetical" && currentSortMode != "date" && + currentSortMode != "favorites" && currentSortMode != "date_oldest") { + currentSortMode = "alphabetical"; + } QFrame *container = new QFrame(this); container->setStyleSheet(R"( @@ -50,6 +105,43 @@ ExpandableMultiOptionDialog::ExpandableMultiOptionDialog(const QString &prompt_t padding-left: 80px; } QPushButton.series-header:hover { background-color: #404040; } + QPushButton.favorite-button { + background-color: transparent; + border: none; + font-size: 60px; + padding: 0px; + margin: 0px; + min-width: 80px; + max-width: 80px; + } + QPushButton.favorite-button:hover { background-color: #404040; } + QComboBox { + background-color: #4F4F4F; + border: 2px solid transparent; + border-radius: 10px; + padding: 10px; + font-size: 50px; + color: white; + min-width: 200px; + } + QComboBox:hover { background-color: #5A5A5A; } + QComboBox::drop-down { + border: none; + width: 50px; + } + QComboBox::down-arrow { + image: url("../../frogpilot/assets/toggle_icons/icon_dropdown.png"); + width: 30px; + height: 30px; + } + QComboBox QAbstractItemView { + background-color: #4F4F4F; + border: 2px solid #FFFFFF; + border-radius: 10px; + color: white; + selection-background-color: #465BEA; + font-size: 50px; + } )"); QVBoxLayout *main_layout = new QVBoxLayout(container); @@ -60,85 +152,103 @@ ExpandableMultiOptionDialog::ExpandableMultiOptionDialog(const QString &prompt_t main_layout->addWidget(title, 0, Qt::AlignLeft | Qt::AlignTop); main_layout->addSpacing(25); - QWidget *listWidget = new QWidget(this); - QVBoxLayout *listLayout = new QVBoxLayout(listWidget); - listLayout->setSpacing(10); + // Sort controls - simple cycling button + QHBoxLayout *sortLayout = new QHBoxLayout(); + sortLayout->setContentsMargins(0, 0, 0, 0); + sortLayout->setSpacing(20); + sortLayout->addStretch(); // Push to the right - QButtonGroup *group = new QButtonGroup(listWidget); - group->setExclusive(true); + QLabel *sortLabel = new QLabel(tr("Sort by:"), this); + sortLabel->setStyleSheet("font-size: 50px; color: white;"); + sortLayout->addWidget(sortLabel); - QPushButton *confirm_btn = new QPushButton(tr("Select")); - confirm_btn->setObjectName("confirm_btn"); - confirm_btn->setEnabled(false); - - ScrollView *scroll_view = new ScrollView(listWidget, this); - scroll_view->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - - // Create series headers and their expandable content - for (const QString &series : seriesToModels.keys()) { - // Series header button - QPushButton *seriesHeader = new QPushButton("▶ " + series); - seriesHeader->setProperty("class", "series-header"); - seriesHeader->setCheckable(false); - seriesExpanded[series] = false; - - QObject::connect(seriesHeader, &QPushButton::clicked, [this, series, seriesHeader, scroll_view]() { - toggleSeries(series, seriesHeader, scroll_view); - }); - - listLayout->addWidget(seriesHeader); - - // Container for series models (initially hidden) - QWidget *seriesContainer = new QWidget(); - QVBoxLayout *seriesLayout = new QVBoxLayout(seriesContainer); - seriesLayout->setContentsMargins(20, 0, 0, 0); - seriesLayout->setSpacing(10); - seriesContainer->hide(); - - // Add models for this series - for (const QString &model : seriesToModels[series]) { - QPushButton *modelButton = new QPushButton(model); - modelButton->setCheckable(true); - modelButton->setChecked(model == current); - modelButton->setProperty("class", "model-option"); - - QObject::connect(modelButton, &QPushButton::toggled, [=](bool checked) mutable { - if (checked) { - selection = model; - confirm_btn->setEnabled(true); - // Manually apply selected style - modelButton->setStyleSheet("QPushButton {" - "background-color: #465BEA;" - "border: 3px solid #FFFFFF;" - "color: white;" - "font-weight: 500;" - "height: 135;" - "padding: 0px 50px;" - "text-align: left;" - "font-size: 55px;" - "border-radius: 10px;" - "}"); - } else { - if (selection == model) { - confirm_btn->setEnabled(false); - } - // Reset to default style - modelButton->setStyleSheet(""); - } - }); - - group->addButton(modelButton); - seriesLayout->addWidget(modelButton); + QPushButton *sortButton = new QPushButton(tr("Alphabetical"), this); + sortButton->setStyleSheet(R"( + QPushButton { + background-color: #4F4F4F; + border: 2px solid transparent; + border-radius: 10px; + padding: 10px 20px; + font-size: 50px; + color: white; + min-width: 250px; + text-align: center; } + QPushButton:hover { background-color: #5A5A5A; } + )"); - seriesWidgets[series] = seriesContainer; - listLayout->addWidget(seriesContainer); + // Set initial button text based on sort mode + if (currentSortMode == "date") { + sortButton->setText(tr("Date (Newest)")); + } else if (currentSortMode == "date_oldest") { + sortButton->setText(tr("Date (Oldest)")); + } else if (currentSortMode == "favorites") { + sortButton->setText(tr("Favorites First")); + } else { + sortButton->setText(tr("Alphabetical")); } - // Add stretch to keep buttons spaced correctly - listLayout->addStretch(1); + QWidget *sortWidget = new QWidget(container); + sortWidget->setLayout(sortLayout); + sortWidget->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum); + sortLayout->setSizeConstraint(QLayout::SetFixedSize); + sortWidget->setStyleSheet("background: transparent;"); - main_layout->addWidget(scroll_view); + sortLayout->addWidget(sortButton); + + auto updateSortOverlayGeometry = [sortWidget, sortLayout]() { + if (!sortWidget) return; + const QSize hint = sortLayout->sizeHint(); + sortWidget->setFixedSize(hint); + }; + updateSortOverlayGeometry(); + + QObject::connect(sortButton, &QPushButton::clicked, [this, sortButton, updateSortOverlayGeometry]() { + if (currentSortMode == "alphabetical") { + currentSortMode = "date"; + sortButton->setText(tr("Date (Newest)")); + } else if (currentSortMode == "date") { + currentSortMode = "date_oldest"; + sortButton->setText(tr("Date (Oldest)")); + } else if (currentSortMode == "date_oldest") { + currentSortMode = "favorites"; + sortButton->setText(tr("Favorites First")); + } else { + currentSortMode = "alphabetical"; + sortButton->setText(tr("Alphabetical")); + } + updateSortOverlayGeometry(); + updateSorting(); + }); + + listWidgetContainer = new QWidget(this); + listLayout = new QVBoxLayout(listWidgetContainer); + listLayout->setSpacing(10); + listLayout->setContentsMargins(0, 0, 0, 0); + + confirmButton = new QPushButton(tr("Select")); + confirmButton->setObjectName("confirm_btn"); + confirmButton->setEnabled(!selectionKey.isEmpty()); + + scrollView = new ScrollView(listWidgetContainer, this); + scrollView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + if (scrollView->viewport()) { + scrollView->viewport()->setAttribute(Qt::WA_AcceptTouchEvents, true); + } + + QWidget *listContainer = new QWidget(container); + QGridLayout *overlayLayout = new QGridLayout(listContainer); + overlayLayout->setContentsMargins(0, 0, 0, 0); + overlayLayout->setSpacing(0); + overlayLayout->addWidget(scrollView, 0, 0); + overlayLayout->setRowStretch(0, 1); + overlayLayout->setColumnStretch(0, 1); + overlayLayout->addWidget(sortWidget, 0, 0, Qt::AlignRight | Qt::AlignTop); + + // Create series headers and their expandable content + rebuildModelList(seriesToModels.keys(), seriesToModels); + + main_layout->addWidget(listContainer); main_layout->addSpacing(35); // Cancel + confirm buttons @@ -148,18 +258,25 @@ ExpandableMultiOptionDialog::ExpandableMultiOptionDialog(const QString &prompt_t QPushButton *cancel_btn = new QPushButton(tr("Cancel")); QObject::connect(cancel_btn, &QPushButton::clicked, this, &ConfirmationDialog::reject); - QObject::connect(confirm_btn, &QPushButton::clicked, this, &ConfirmationDialog::accept); + QObject::connect(confirmButton, &QPushButton::clicked, this, &ConfirmationDialog::accept); blayout->addWidget(cancel_btn); - blayout->addWidget(confirm_btn); + blayout->addWidget(confirmButton); QVBoxLayout *outer_layout = new QVBoxLayout(this); outer_layout->setContentsMargins(50, 50, 50, 50); outer_layout->addWidget(container); + + // Initial sorting + updateSorting(); } -void ExpandableMultiOptionDialog::toggleSeries(const QString &series, QPushButton *headerButton, ScrollView *scrollView) { +void ExpandableMultiOptionDialog::toggleSeries(const QString &series, QPushButton *headerButton) { + if (!headerButton) return; + + QWidget *container = seriesWidgets.value(series, nullptr); + if (!container) return; + bool expanded = seriesExpanded[series]; - QWidget *container = seriesWidgets[series]; QString seriesName = series; if (expanded) { @@ -171,21 +288,18 @@ void ExpandableMultiOptionDialog::toggleSeries(const QString &series, QPushButto seriesExpanded[series] = true; headerButton->setText("▼ " + seriesName); - // Auto-scroll to show expanded content + // Auto-scroll to place the series at the top of the viewport when expanded if (scrollView) { - QTimer::singleShot(50, [container, scrollView]() { - QRect containerRect = container->geometry(); - QScrollBar *vScrollBar = scrollView->verticalScrollBar(); - if (vScrollBar) { - int currentValue = vScrollBar->value(); - int containerBottom = containerRect.bottom(); - int viewportHeight = scrollView->viewport()->height(); - - // If container extends beyond viewport, scroll to show it - if (containerBottom > currentValue + viewportHeight) { - int targetValue = containerBottom - viewportHeight + 50; // Add some padding - vScrollBar->setValue(targetValue); - } + QPointer headerPtr(headerButton); + QPointer scrollPtr(scrollView); + QTimer::singleShot(50, [headerPtr, scrollPtr]() { + if (!scrollPtr || !headerPtr) return; + QWidget *contents = scrollPtr->widget(); + if (!contents) return; + if (QScrollBar *vScrollBar = scrollPtr->verticalScrollBar()) { + QPoint headerTop = headerPtr->mapTo(contents, QPoint(0, 0)); + int targetValue = qMax(headerTop.y() - 20, 0); + vScrollBar->setValue(targetValue); } }); } @@ -196,11 +310,457 @@ void ExpandableMultiOptionDialog::toggleSeries(const QString &series, QPushButto } QString ExpandableMultiOptionDialog::getSelection(const QString &prompt_text, - const QMap &seriesToModels, - const QString ¤t, QWidget *parent) { - ExpandableMultiOptionDialog d = ExpandableMultiOptionDialog(prompt_text, seriesToModels, current, parent); + const QMap &seriesToModels, + const QString ¤t, QWidget *parent, + const QStringList &userFavorites, + const QStringList &communityFavorites, + const QMap &modelReleasedDates, + const QMap &modelFileToNameMap, + const QString &initialSortMode) { + ExpandableMultiOptionDialog d(prompt_text, seriesToModels, current, parent, + userFavorites, communityFavorites, modelReleasedDates, modelFileToNameMap, initialSortMode); if (d.exec()) { return d.selection; } return ""; -} \ No newline at end of file +} + +QStringList ExpandableMultiOptionDialog::getUserFavorites() const { + QStringList filteredFavorites; + for (const QString &fav : userFavorites) { + if (modelFileToNameMap.contains(fav) && !filteredFavorites.contains(fav)) { + filteredFavorites.append(fav); + } + } + return filteredFavorites; +} + +void ExpandableMultiOptionDialog::stopActiveScroll() { + if (!scrollView) { + return; + } + + if (QScroller *scroller = QScroller::scroller(scrollView->viewport())) { + if (scroller->state() == QScroller::Scrolling) { + scroller->stop(); + } + } +} + +void ExpandableMultiOptionDialog::stopActiveScrollForInteraction() { + if (!scrollView) { + return; + } + + if (QScroller *scroller = QScroller::scroller(scrollView->viewport())) { + const QScroller::State state = scroller->state(); + if (state == QScroller::Scrolling || state == QScroller::Dragging || state == QScroller::Pressed) { + scroller->stop(); + } + } +} + +void ExpandableMultiOptionDialog::createModelButton(const QString &modelKey, const QString &modelName, const QString &displayName, + QVBoxLayout *layout) { + QString effectiveKey = modelKey.isEmpty() ? modelName : modelKey; + if (effectiveKey.isEmpty()) { + return; + } + + if (!modelFileToNameMap.contains(effectiveKey)) { + const QString storedName = !modelName.isEmpty() ? modelName : displayName; + modelFileToNameMap.insert(effectiveKey, storedName); + } + + if (!modelName.isEmpty()) { + modelNameToFileMap.insert(modelName, effectiveKey); + } + + QWidget *modelWidget = new QWidget(); + QHBoxLayout *modelLayout = new QHBoxLayout(modelWidget); + modelLayout->setContentsMargins(0, 0, 0, 0); + modelLayout->setSpacing(10); + + // Star button + QPushButton *starButton = new QPushButton(); + starButton->setProperty("class", "favorite-button"); + starButton->setCheckable(true); + starButton->setCursor(Qt::PointingHandCursor); + starButton->setFocusPolicy(Qt::NoFocus); + + // Check if this model is a favorite + bool isCommunityFav = communityFavorites.contains(effectiveKey); + bool isUserFav = userFavorites.contains(effectiveKey); + bool isFavorite = isCommunityFav || isUserFav; + + starButton->setChecked(isFavorite); + starButton->setText(isFavorite ? QString::fromUtf16(u"\u2665") : QString::fromUtf16(u"\u2661")); + + QObject::connect(starButton, &QPushButton::clicked, [this, effectiveKey]() { + stopActiveScrollForInteraction(); + toggleFavorite(effectiveKey); + }); + + favoriteButtons[effectiveKey].append(starButton); + modelLayout->addWidget(starButton); + + // Model button + QPushButton *modelButton = new QPushButton(displayName); + modelButton->setCheckable(true); + modelButton->setProperty("class", "model-option"); + modelButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + modelButton->setCursor(Qt::PointingHandCursor); + modelButton->setFocusPolicy(Qt::NoFocus); + modelButton->setProperty("modelKey", effectiveKey); + modelButton->setProperty("modelName", modelName); + + modelButtons[effectiveKey].append(modelButton); + if (selectionKey == effectiveKey && currentSelectionButton.isNull()) { + currentSelectionButton = modelButton; + } + modelLayout->addWidget(modelButton); + + const QString resolvedSelection = modelFileToNameMap.value(effectiveKey, !modelName.isEmpty() ? modelName : displayName); + + QObject::connect(modelButton, &QPushButton::clicked, this, [this, effectiveKey, modelButton, resolvedSelection]() { + stopActiveScrollForInteraction(); + selectionKey = effectiveKey; + currentSelectionKey = effectiveKey; + selection = resolvedSelection; + currentSelection = resolvedSelection; + currentSelectionButton = modelButton; + if (confirmButton) { + confirmButton->setEnabled(true); + } + + updateButtonStyles(); + }); + + layout->addWidget(modelWidget); +} + +void ExpandableMultiOptionDialog::toggleFavorite(const QString &modelKey) { + // Update local state + if (modelKey.isEmpty()) { + return; + } + + if (userFavorites.contains(modelKey)) { + userFavorites.removeAll(modelKey); + } else { + userFavorites.append(modelKey); + } + + updateSorting(); +} + +void ExpandableMultiOptionDialog::updateSorting() { + const QString favoritesSeriesName = QStringLiteral("♥ Favorites"); + QMap newSeriesToModels; + QStringList orderedSeries; + QSet validSeries; + QSet favoriteModelKeys; + QSet availableModelKeys; + displayOverrides.clear(); + + const bool sortByDate = (currentSortMode == "date" || currentSortMode == "date_oldest"); + const bool sortDateNewestFirst = (currentSortMode == "date"); + + for (auto it = baseSeriesToModels.constBegin(); it != baseSeriesToModels.constEnd(); ++it) { + const QStringList &models = it.value(); + for (const QString &modelName : models) { + const QString modelKey = modelNameToFileMap.value(modelName, modelName); + if (!modelKey.isEmpty()) { + availableModelKeys.insert(modelKey); + } + } + } + + if (currentSortMode == "favorites") { + QStringList favoritesList; + + for (const QString &modelKey : communityFavorites) { + if (availableModelKeys.contains(modelKey)) { + const QString modelName = modelFileToNameMap.value(modelKey); + favoritesList.append(modelName); + favoriteModelKeys.insert(modelKey); + displayOverrides.insert(modelKey, tr("%1 (Community Fav)").arg(modelName)); + } + } + + for (const QString &modelKey : userFavorites) { + if (availableModelKeys.contains(modelKey) && !favoriteModelKeys.contains(modelKey)) { + favoritesList.append(modelFileToNameMap.value(modelKey)); + favoriteModelKeys.insert(modelKey); + } + } + + if (!favoritesList.isEmpty()) { + std::sort(favoritesList.begin(), favoritesList.end()); + newSeriesToModels.insert(favoritesSeriesName, favoritesList); + orderedSeries.append(favoritesSeriesName); + validSeries.insert(favoritesSeriesName); + seriesExpanded.insert(favoritesSeriesName, true); + } else { + seriesExpanded.remove(favoritesSeriesName); + } + } else { + seriesExpanded.remove(favoritesSeriesName); + } + + struct SeriesInfo { + QString name; + QStringList models; + QString newestDate; + QString oldestDate; + }; + + QVector seriesInfos; + + for (auto it = baseSeriesToModels.constBegin(); it != baseSeriesToModels.constEnd(); ++it) { + QString series = it.key(); + QStringList models = it.value(); + + if (sortByDate) { + std::sort(models.begin(), models.end(), [this, sortDateNewestFirst](const QString &a, const QString &b) { + QString keyA = modelNameToFileMap.value(a, a); + QString keyB = modelNameToFileMap.value(b, b); + QString dateA = modelReleasedDates.value(keyA, QStringLiteral("1970-01-01")); + QString dateB = modelReleasedDates.value(keyB, QStringLiteral("1970-01-01")); + if (dateA == dateB) { + return a < b; + } + return sortDateNewestFirst ? (dateA > dateB) : (dateA < dateB); + }); + } else { + std::sort(models.begin(), models.end()); + } + + if (currentSortMode == "favorites" && !favoriteModelKeys.isEmpty()) { + QStringList filteredModels; + for (const QString &modelName : models) { + QString key = modelNameToFileMap.value(modelName, modelName); + if (!favoriteModelKeys.contains(key)) { + filteredModels.append(modelName); + } + } + models = filteredModels; + } + + if (models.isEmpty()) { + continue; + } + + QString newestDate = QStringLiteral("1970-01-01"); + QString oldestDate = QStringLiteral("1970-01-01"); + bool hasDate = false; + for (const QString &modelName : models) { + const QString key = modelNameToFileMap.value(modelName, modelName); + const QString date = modelReleasedDates.value(key, QStringLiteral("1970-01-01")); + if (!hasDate) { + newestDate = date; + oldestDate = date; + hasDate = true; + } else { + if (date > newestDate) { + newestDate = date; + } + if (date < oldestDate) { + oldestDate = date; + } + } + } + + if (!hasDate) { + oldestDate = QStringLiteral("1970-01-01"); + } + + seriesInfos.push_back({series, models, newestDate, oldestDate}); + newSeriesToModels.insert(series, models); + } + + if (sortByDate) { + std::sort(seriesInfos.begin(), seriesInfos.end(), [sortDateNewestFirst](const SeriesInfo &a, const SeriesInfo &b) { + if (sortDateNewestFirst) { + if (a.newestDate == b.newestDate) { + return a.name < b.name; + } + return a.newestDate > b.newestDate; + } else { + if (a.oldestDate == b.oldestDate) { + return a.name < b.name; + } + return a.oldestDate < b.oldestDate; + } + }); + } else { + std::sort(seriesInfos.begin(), seriesInfos.end(), [](const SeriesInfo &a, const SeriesInfo &b) { + return a.name < b.name; + }); + } + + for (const SeriesInfo &info : seriesInfos) { + orderedSeries.append(info.name); + validSeries.insert(info.name); + } + + for (auto it = seriesExpanded.begin(); it != seriesExpanded.end(); ) { + if (!validSeries.contains(it.key())) { + it = seriesExpanded.erase(it); + } else { + ++it; + } + } + + rebuildModelList(orderedSeries, newSeriesToModels); + refreshFavoriteIcons(); +} + +void ExpandableMultiOptionDialog::rebuildModelList(const QStringList &orderedSeries, const QMap &newSeriesToModels) { + if (!listLayout) return; + + stopActiveScroll(); + + while (QLayoutItem *item = listLayout->takeAt(0)) { + if (QWidget *w = item->widget()) { + delete w; + } else if (QLayout *layout = item->layout()) { + delete layout; + } + delete item; + } + + seriesWidgets.clear(); + modelButtons.clear(); + favoriteButtons.clear(); + currentSelectionButton = nullptr; + + for (const QString &series : orderedSeries) { + const QStringList models = newSeriesToModels.value(series); + if (models.isEmpty()) { + continue; + } + + QPushButton *seriesHeader = new QPushButton("▶ " + series); + seriesHeader->setProperty("class", "series-header"); + seriesHeader->setCheckable(false); + + bool expanded = seriesExpanded.value(series, false); + seriesExpanded.insert(series, expanded); + + QObject::connect(seriesHeader, &QPushButton::clicked, [this, series, seriesHeader]() { + toggleSeries(series, seriesHeader); + }); + + QWidget *seriesContainer = new QWidget(); + QVBoxLayout *seriesLayout = new QVBoxLayout(seriesContainer); + seriesLayout->setContentsMargins(20, 0, 0, 0); + seriesLayout->setSpacing(10); + + for (const QString &modelName : models) { + QString modelKey = modelNameToFileMap.value(modelName, modelName); + if (!modelFileToNameMap.contains(modelKey)) { + modelFileToNameMap.insert(modelKey, modelName); + } + QString displayName = displayOverrides.value(modelKey, modelName); + createModelButton(modelKey, modelName, displayName, seriesLayout); + } + + if (expanded) { + seriesContainer->show(); + seriesHeader->setText("▼ " + series); + } else { + seriesContainer->hide(); + seriesHeader->setText("▶ " + series); + } + + seriesWidgets.insert(series, seriesContainer); + + listLayout->addWidget(seriesHeader); + listLayout->addWidget(seriesContainer); + } + + listLayout->addStretch(1); + + seriesToModels = newSeriesToModels; + + listWidgetContainer->updateGeometry(); + listWidgetContainer->adjustSize(); + if (scrollView && scrollView->widget()) { + scrollView->widget()->updateGeometry(); + scrollView->widget()->adjustSize(); + } + + updateButtonStyles(); +} + +void ExpandableMultiOptionDialog::refreshFavoriteIcons() { + for (auto it = favoriteButtons.begin(); it != favoriteButtons.end(); ++it) { + const QString &modelKey = it.key(); + const QList &buttons = it.value(); + bool isCommunityFav = communityFavorites.contains(modelKey); + bool isUserFav = userFavorites.contains(modelKey); + bool isFavorite = isCommunityFav || isUserFav; + + for (QPushButton *button : buttons) { + if (!button) continue; + button->setChecked(isFavorite); + button->setText(isFavorite ? QString::fromUtf16(u"\u2665") : QString::fromUtf16(u"\u2661")); + } + } + + if (confirmButton && !selectionKey.isEmpty()) { + confirmButton->setEnabled(true); + } + + updateButtonStyles(); +} + +void ExpandableMultiOptionDialog::updateButtonStyles() { + const QString selectedKey = selectionKey; + const QString selectedStyle = QStringLiteral( + "QPushButton {" + "background-color: #465BEA;" + "border: 3px solid #FFFFFF;" + "color: white;" + "font-weight: 500;" + "height: 135;" + "padding: 0px 50px;" + "text-align: left;" + "font-size: 55px;" + "border-radius: 10px;" + "}"); + + if (selectedKey.isEmpty()) { + currentSelectionButton = nullptr; + } + + QPushButton *explicitButton = currentSelectionButton.data(); + if (explicitButton && explicitButton->property("modelKey").toString() != selectedKey) { + explicitButton = nullptr; + } + + for (auto it = modelButtons.begin(); it != modelButtons.end(); ++it) { + const QString &modelKey = it.key(); + const QList &buttons = it.value(); + const bool keyMatches = (!selectedKey.isEmpty() && modelKey == selectedKey); + bool activatedForKey = false; + + for (QPushButton *button : buttons) { + if (!button) continue; + + bool isActive = false; + if (explicitButton) { + isActive = (button == explicitButton); + } else if (keyMatches && !activatedForKey) { + isActive = true; + activatedForKey = true; + currentSelectionButton = button; + } + + QSignalBlocker blocker(button); + button->setChecked(isActive); + button->setStyleSheet(isActive ? selectedStyle : QString()); + } + } +} diff --git a/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h b/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h index e89a0101e..2c0b1e61d 100644 --- a/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h +++ b/frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h @@ -6,23 +6,71 @@ #include #include #include +#include +#include +#include #include "selfdrive/ui/qt/widgets/input.h" #include "selfdrive/ui/qt/widgets/scrollview.h" +class QPushButton; class ExpandableMultiOptionDialog : public DialogBase { Q_OBJECT public: explicit ExpandableMultiOptionDialog(const QString &prompt_text, const QMap &seriesToModels, - const QString ¤t, QWidget *parent); + const QString ¤t, QWidget *parent, + const QStringList &userFavorites = QStringList(), + const QStringList &communityFavorites = QStringList(), + const QMap &modelReleasedDates = QMap(), + const QMap &modelFileToNameMap = QMap(), + const QString &initialSortMode = "alphabetical"); static QString getSelection(const QString &prompt_text, const QMap &seriesToModels, - const QString ¤t, QWidget *parent); + const QString ¤t, QWidget *parent, + const QStringList &userFavorites = QStringList(), + const QStringList &communityFavorites = QStringList(), + const QMap &modelReleasedDates = QMap(), + const QMap &modelFileToNameMap = QMap(), + const QString &initialSortMode = QString()); QString selection; + QString getCurrentSortMode() const { return currentSortMode; } + QStringList getUserFavorites() const; + private: - void toggleSeries(const QString &series, QPushButton *headerButton, ScrollView *scrollView); + void toggleSeries(const QString &series, QPushButton *headerButton); + void toggleFavorite(const QString &modelKey); + void updateSorting(); + void rebuildModelList(const QStringList &orderedSeries, const QMap &newSeriesToModels); + void createModelButton(const QString &modelKey, const QString &modelName, const QString &displayName, + QVBoxLayout *layout); + void refreshFavoriteIcons(); + void updateButtonStyles(); + void stopActiveScroll(); + void stopActiveScrollForInteraction(); + QMap seriesToModels; + QMap baseSeriesToModels; QMap seriesWidgets; QMap seriesExpanded; -}; \ No newline at end of file + QMap> modelButtons; + QMap> favoriteButtons; + + QStringList userFavorites; + QStringList communityFavorites; + QMap modelReleasedDates; + QMap modelFileToNameMap; + QMap modelNameToFileMap; + QMap displayOverrides; + + QString currentSortMode; + QString currentSelection; + QString currentSelectionKey; + QString selectionKey; + + ScrollView *scrollView = nullptr; + QVBoxLayout *listLayout = nullptr; + QPushButton *confirmButton = nullptr; + QWidget *listWidgetContainer = nullptr; + QPointer currentSelectionButton; +}; diff --git a/frogpilot/ui/qt/offroad/model_settings.cc b/frogpilot/ui/qt/offroad/model_settings.cc index 669f5382d..e05b3752a 100644 --- a/frogpilot/ui/qt/offroad/model_settings.cc +++ b/frogpilot/ui/qt/offroad/model_settings.cc @@ -1,10 +1,13 @@ #include "frogpilot/ui/qt/offroad/model_settings.h" #include "frogpilot/ui/qt/offroad/expandable_multi_option_dialog.h" #include +#include #include #include #include #include +#include +#include FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : FrogPilotListWidget(parent), parent(parent) { QStackedLayout *modelLayout = new QStackedLayout(); @@ -41,56 +44,72 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog if (param == "DeleteModel") { deleteModelButton = new FrogPilotButtonsControl(title, desc, icon, {tr("DELETE"), tr("DELETE ALL")}); QObject::connect(deleteModelButton, &FrogPilotButtonsControl::buttonClicked, [this](int id) { - QStringList deletableModels; - for (const QString &file : modelDir.entryList(QDir::Files)) { - QString base = QFileInfo(file).baseName(); - for (const QString &modelKey : modelFileToNameMapProcessed.keys()) { - if (base.startsWith(modelKey)) { - QString modelName = modelFileToNameMapProcessed.value(modelKey); - if (!deletableModels.contains(modelName)) { - deletableModels.append(modelName); - } - } - } + QMap deletableModelsMap = getDeletableModelDisplayNames(); + noModelsDownloaded = deletableModelsMap.isEmpty(); + + if (noModelsDownloaded) { + return; } - deletableModels.removeAll(processModelName(currentModel)); - deletableModels.removeAll(modelFileToNameMapProcessed.value(QString::fromStdString(params_default.get("Model")))); - deletableModels.removeAll("Space Lab"); - noModelsDownloaded = deletableModels.isEmpty(); if (id == 0) { - // Group deletable models by series + // Group deletable models by series and keep a lookup for selected names QMap deletableSeriesToModels; - for (const QString &modelName : deletableModels) { - QString modelKey = modelFileToNameMapProcessed.key(modelName); - QString series = modelSeriesMap.value(modelKey, "Custom Series"); - deletableSeriesToModels[series].append(modelName); + QMap displayNameToKey; + QMap deletableFileToNameMap; + for (auto it = deletableModelsMap.constBegin(); it != deletableModelsMap.constEnd(); ++it) { + const QString &modelKey = it.key(); + const QString &displayName = it.value(); + QString series = modelSeriesMap.value(modelKey, tr("Custom Series")); + deletableSeriesToModels[series].append(displayName); + displayNameToKey.insert(displayName, modelKey); + deletableFileToNameMap.insert(modelKey, displayName); } // Sort models within each series for (QString &series : deletableSeriesToModels.keys()) { - deletableSeriesToModels[series].sort(); + QStringList &models = deletableSeriesToModels[series]; + models.removeDuplicates(); + std::sort(models.begin(), models.end()); } - QString modelToDelete = ExpandableMultiOptionDialog::getSelection(tr("Select a driving model to delete"), deletableSeriesToModels, "", this); - if (!modelToDelete.isEmpty() && ConfirmationDialog::confirm(tr("Are you sure you want to delete the \"%1\" model?").arg(modelToDelete), tr("Delete"), this)) { - QString modelFile = modelFileToNameMapProcessed.key(modelToDelete); - for (const QString &file : modelDir.entryList(QDir::Files)) { - QString base = QFileInfo(file).baseName(); - if (base.startsWith(modelFile)) { - QFile::remove(modelDir.filePath(file)); + QString savedSortMode = QString::fromStdString(params.get("ModelSortMode")); + if (savedSortMode.isEmpty()) savedSortMode = "alphabetical"; + + QString modelToDelete = ExpandableMultiOptionDialog::getSelection(tr("Select a driving model to delete"), deletableSeriesToModels, "", this, + QStringList(), QStringList(), QMap(), + deletableFileToNameMap, savedSortMode); + if (!modelToDelete.isEmpty()) { + QString modelKey = displayNameToKey.value(modelToDelete); + if (modelKey.isEmpty()) { + QString processedName = processModelName(modelToDelete); + for (auto it = deletableModelsMap.constBegin(); it != deletableModelsMap.constEnd(); ++it) { + if (processModelName(it.value()) == processedName) { + modelKey = it.key(); + break; + } } } - allModelsDownloaded = false; + if (!modelKey.isEmpty() && ConfirmationDialog::confirm(tr("Are you sure you want to delete the \"%1\" model?").arg(modelToDelete), tr("Delete"), this)) { + for (const QString &file : modelDir.entryList(QDir::Files)) { + QString base = QFileInfo(file).baseName(); + if (base.startsWith(modelKey)) { + QFile::remove(modelDir.filePath(file)); + } + } + + allModelsDownloaded = false; + noModelsDownloaded = getDeletableModelDisplayNames().isEmpty(); + deleteModelButton->setEnabled(!(allModelsDownloading || modelDownloading || noModelsDownloaded)); + } } } else if (id == 1) { if (ConfirmationDialog::confirm(tr("Are you sure you want to delete all of your downloaded driving models?"), tr("Delete"), this)) { + const QList deletableKeys = deletableModelsMap.keys(); for (const QString &file : modelDir.entryList(QDir::Files)) { QString base = QFileInfo(file).baseName(); - for (const QString &modelKey : modelFileToNameMapProcessed.keys()) { - QString modelName = modelFileToNameMapProcessed.value(modelKey); - if (deletableModels.contains(modelName) && base.startsWith(modelKey)) { + for (const QString &modelKey : deletableKeys) { + if (base.startsWith(modelKey)) { QFile::remove(modelDir.filePath(file)); break; } @@ -99,6 +118,7 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog allModelsDownloaded = false; noModelsDownloaded = true; + deleteModelButton->setEnabled(false); } } }); @@ -106,66 +126,70 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog } else if (param == "DownloadModel") { downloadModelButton = new FrogPilotButtonsControl(title, desc, icon, {tr("DOWNLOAD"), tr("DOWNLOAD ALL")}); QObject::connect(downloadModelButton, &FrogPilotButtonsControl::buttonClicked, [this](int id) { - auto isInstalled = [this](const QString &key) { - bool has_thneed = false; - bool has_policy_meta = false; - bool has_policy_tg = false; - bool has_vision_meta = false; - bool has_vision_tg = false; - - for (const QString &file : modelDir.entryList(QDir::Files)) { - QFileInfo fi(modelDir.filePath(file)); - const QString base = fi.baseName(); - const QString ext = fi.suffix(); - if (!(base.startsWith(key) || base.startsWith(key + "_"))) continue; - - if (ext == "thneed") { - // Classic model (WD-40 etc.) - has_thneed = true; - } else if (ext == "pkl") { - // TinyGrad bundle uses these four exact suffixes - if (base.contains("_driving_policy_metadata")) has_policy_meta = true; - else if (base.contains("_driving_policy_tinygrad")) has_policy_tg = true; - else if (base.contains("_driving_vision_metadata")) has_vision_meta = true; - else if (base.contains("_driving_vision_tinygrad")) has_vision_tg = true; - } - } - - // Classic models: any matching .thneed counts as installed - if (has_thneed) return true; - // TinyGrad models: require all four policy/vision files to be present - return has_policy_meta && has_policy_tg && has_vision_meta && has_vision_tg; - }; if (id == 0) { if (modelDownloading) { params_memory.putBool("CancelModelDownload", true); cancellingDownload = true; - } else { - QStringList downloadableModels = availableModelNames; - for (const QString &modelKey : modelFileToNameMap.keys()) { - QString modelName = modelFileToNameMap.value(modelKey); - if (isInstalled(modelKey)) { - downloadableModels.removeAll(modelName); - } - } - downloadableModels.removeAll("Space Lab 👀📡"); - allModelsDownloaded = downloadableModels.isEmpty(); + } else { + QMap downloadableSeriesToModels; + QStringList downloadableModelNames; - // Group downloadable models by series - QMap downloadableSeriesToModels; - for (const QString &modelName : downloadableModels) { - QString modelKey = modelFileToNameMap.key(modelName); - QString series = modelSeriesMap.value(modelKey, "Custom Series"); - downloadableSeriesToModels[series].append(modelName); + for (auto it = modelFileToNameMap.constBegin(); it != modelFileToNameMap.constEnd(); ++it) { + const QString &modelKey = it.key(); + const QString &modelName = it.value(); + if (modelName.isEmpty() || isModelInstalled(modelKey)) { + continue; } - // Sort models within each series - for (QString &series : downloadableSeriesToModels.keys()) { - downloadableSeriesToModels[series].sort(); + QString series = modelSeriesMap.value(modelKey, tr("Custom Series")); + downloadableSeriesToModels[series].append(modelName); + if (!downloadableModelNames.contains(modelName)) { + downloadableModelNames.append(modelName); } + } - QString modelToDownload = ExpandableMultiOptionDialog::getSelection(tr("Select a driving model to download"), downloadableSeriesToModels, "", this); + allModelsDownloaded = downloadableModelNames.isEmpty(); + if (allModelsDownloaded) { + return; + } + + for (QString &series : downloadableSeriesToModels.keys()) { + QStringList &models = downloadableSeriesToModels[series]; + models.removeDuplicates(); + std::sort(models.begin(), models.end()); + } + + QStringList userFavorites = QString::fromStdString(params.get("UserFavorites")).split(","); + userFavorites.removeAll(""); + + QStringList communityFavorites = QString::fromStdString(params.get("CommunityFavorites")).split(","); + communityFavorites.removeAll(""); + + QString savedSortMode = QString::fromStdString(params.get("ModelSortMode")); + if (savedSortMode.isEmpty()) savedSortMode = "alphabetical"; + + ExpandableMultiOptionDialog dialog( + tr("Select a driving model to download"), + downloadableSeriesToModels, + "", + this, + userFavorites, + communityFavorites, + modelReleasedDates, + modelFileToNameMap, + savedSortMode); + + int dialogResult = dialog.exec(); + + QString sortMode = dialog.getCurrentSortMode(); + QStringList newUserFavs = dialog.getUserFavorites(); + params.put("ModelSortMode", sortMode.toStdString()); + params.put("UserFavorites", newUserFavs.join(",").toStdString()); + userFavorites = newUserFavs; + + if (dialogResult == QDialog::Accepted) { + QString modelToDownload = dialog.selection; if (!modelToDownload.isEmpty()) { QString modelKey = modelFileToNameMap.key(modelToDownload); params_memory.put("ModelToDownload", modelKey.toStdString()); @@ -182,15 +206,16 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog } } } - params_memory.put("ModelDownloadProgress", "Downloading..."); + params_memory.put("ModelDownloadProgress", "Downloading..."); - downloadModelButton->setText(0, tr("CANCEL")); + downloadModelButton->setText(0, tr("CANCEL")); - downloadModelButton->setValue("Downloading..."); + downloadModelButton->setValue("Downloading..."); - downloadModelButton->setVisibleButton(1, false); + downloadModelButton->setVisibleButton(1, false); - modelDownloading = true; + modelDownloading = true; + } } } } else if (id == 1) { @@ -308,57 +333,32 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog } else if (param == "SelectModel") { selectModelButton = new ButtonControl(title, tr("SELECT"), desc); QObject::connect(selectModelButton, &ButtonControl::clicked, [this]() { - auto isInstalled = [this](const QString &key) { - bool has_thneed = false; - bool has_policy_meta = false; - bool has_policy_tg = false; - bool has_vision_meta = false; - bool has_vision_tg = false; + // Group models by series for the enhanced dialog + QMap seriesToModels; + QMap installedModelFileToNameMap; + QMap installedReleasedDates; - for (const QString &file : modelDir.entryList(QDir::Files)) { - QFileInfo fi(modelDir.filePath(file)); - const QString base = fi.baseName(); - const QString ext = fi.suffix(); - if (!(base.startsWith(key) || base.startsWith(key + "_"))) continue; - - if (ext == "thneed") { - // Classic model (WD-40 etc.) - has_thneed = true; - } else if (ext == "pkl") { - // TinyGrad bundle uses these four exact suffixes - if (base.contains("_driving_policy_metadata")) has_policy_meta = true; - else if (base.contains("_driving_policy_tinygrad")) has_policy_tg = true; - else if (base.contains("_driving_vision_metadata")) has_vision_meta = true; - else if (base.contains("_driving_vision_tinygrad")) has_vision_tg = true; - } + // Add all available models by series + for (const QString &modelKey : modelFileToNameMap.keys()) { + if (!isModelInstalled(modelKey)) { + continue; } - // Classic models: any matching .thneed counts as installed - if (has_thneed) return true; - // TinyGrad models: require all four policy/vision files to be present - return has_policy_meta && has_policy_tg && has_vision_meta && has_vision_tg; - }; - // Group models by series - QMap seriesToModels; - for (const QString &modelKey : modelFileToNameMap.keys()) { QString modelName = modelFileToNameMap.value(modelKey); if (modelName.contains("(Default)")) { continue; } - if (isInstalled(modelKey)) { - QString series = modelSeriesMap.value(modelKey, "Dom Forgot To Label Me"); - seriesToModels[series].append(modelName); + installedModelFileToNameMap.insert(modelKey, modelName); + if (modelReleasedDates.contains(modelKey)) { + installedReleasedDates.insert(modelKey, modelReleasedDates.value(modelKey)); } + + QString series = modelSeriesMap.value(modelKey, "Custom Series"); + seriesToModels[series].append(modelName); } - // Add Space Lab to Custom Series - QString spaceLabName = modelFileToNameMap.value("space-lab"); - if (isInstalled("space-lab")) { - seriesToModels["Custom Series"].append(spaceLabName); - } - - // Sort models within each series + // Sort models alphabetically within each series for (QString &series : seriesToModels.keys()) { seriesToModels[series].sort(); } @@ -371,50 +371,62 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog seriesToModels[defaultSeries].prepend(defaultModelName); } - QString modelToSelect = ExpandableMultiOptionDialog::getSelection(tr("Select a model - 🗺️ = Navigation | 📡 = Radar | 👀 = VOACC"), seriesToModels, currentModel, this); - if (!modelToSelect.isEmpty()) { - currentModel = modelToSelect; + // Prepare favorites and dates for the enhanced dialog + QStringList userFavs = QString::fromStdString(params.get("UserFavorites")).split(","); + userFavs.removeAll(""); - params.put("Model", modelFileToNameMap.key(modelToSelect).toStdString()); - // Sync ModelVersion with the selected model if known - { - QString modelKey = modelFileToNameMap.key(modelToSelect); - QFile vf("/data/models/.model_versions.json"); - if (vf.open(QIODevice::ReadOnly)) { - auto doc = QJsonDocument::fromJson(vf.readAll()); - if (doc.isObject()) { - auto obj = doc.object(); - if (obj.contains(modelKey)) { - params.put("ModelVersion", obj.value(modelKey).toString().toStdString()); + QStringList communityFavs = QString::fromStdString(params.get("CommunityFavorites")).split(","); + communityFavs.removeAll(""); + + // Create dialog instance to access sort mode and favorites after selection + QString savedSortMode = QString::fromStdString(params.get("ModelSortMode")); + if (savedSortMode.isEmpty()) savedSortMode = "alphabetical"; + + ExpandableMultiOptionDialog dialog(tr("Select a model - 🗺️ = Navigation | 📡 = Radar | 👀 = VOACC"), + seriesToModels, currentModel, this, + userFavs, communityFavs, installedReleasedDates, installedModelFileToNameMap, savedSortMode); + + int dialogResult = dialog.exec(); + + // Persist sort mode and user favorites even if no selection was made + QString sortMode = dialog.getCurrentSortMode(); + QStringList newUserFavs = dialog.getUserFavorites(); + params.put("ModelSortMode", sortMode.toStdString()); + params.put("UserFavorites", newUserFavs.join(",").toStdString()); + + if (dialogResult == QDialog::Accepted) { + QString modelToSelect = dialog.selection; + if (!modelToSelect.isEmpty()) { + currentModel = modelToSelect; + + params.put("Model", modelFileToNameMap.key(modelToSelect).toStdString()); + // Sync ModelVersion with the selected model if known + { + QString modelKey = modelFileToNameMap.key(modelToSelect); + QFile vf("/data/models/.model_versions.json"); + if (vf.open(QIODevice::ReadOnly)) { + auto doc = QJsonDocument::fromJson(vf.readAll()); + if (doc.isObject()) { + auto obj = doc.object(); + if (obj.contains(modelKey)) { + params.put("ModelVersion", obj.value(modelKey).toString().toStdString()); + } } } } - } - updateFrogPilotToggles(); + updateFrogPilotToggles(); - if (started) { - if (FrogPilotConfirmationDialog::toggleReboot(this)) { - Hardware::reboot(); - } - } - selectModelButton->setValue(modelToSelect); - - QStringList deletableModels; - for (const QString &file : modelDir.entryList(QDir::Files)) { - QString base = QFileInfo(file).baseName(); - for (const QString &modelKey : modelFileToNameMapProcessed.keys()) { - if (base.startsWith(modelKey)) { - QString modelName = modelFileToNameMapProcessed.value(modelKey); - if (!deletableModels.contains(modelName)) { - deletableModels.append(modelName); - } + if (started) { + if (FrogPilotConfirmationDialog::toggleReboot(this)) { + Hardware::reboot(); } } + selectModelButton->setValue(modelToSelect); + + noModelsDownloaded = getDeletableModelDisplayNames().isEmpty(); + deleteModelButton->setEnabled(!(allModelsDownloading || modelDownloading || noModelsDownloaded)); } - deletableModels.removeAll(processModelName(currentModel)); - deletableModels.removeAll(modelFileToNameMapProcessed.value(QString::fromStdString(params_default.get("Model")))); - noModelsDownloaded = deletableModels.isEmpty(); } }); modelToggle = selectModelButton; @@ -465,6 +477,87 @@ FrogPilotModelPanel::FrogPilotModelPanel(FrogPilotSettingsWindow *parent) : Frog QObject::connect(uiState(), &UIState::uiUpdate, this, &FrogPilotModelPanel::updateState); } +bool FrogPilotModelPanel::isModelInstalled(const QString &key) const { + if (key.isEmpty()) { + return false; + } + + bool has_thneed = false; + bool has_policy_meta = false; + bool has_policy_tg = false; + bool has_vision_meta = false; + bool has_vision_tg = false; + bool foundAny = false; + + for (const QString &file : modelDir.entryList(QDir::Files)) { + QFileInfo fi(modelDir.filePath(file)); + const QString base = fi.baseName(); + const QString ext = fi.suffix(); + + if (!(base.startsWith(key) || base.startsWith(key + "_"))) continue; + + foundAny = true; + + if (ext == "thneed") { + has_thneed = true; + } else if (ext == "pkl") { + if (base.contains("_driving_policy_metadata")) { + has_policy_meta = true; + } else if (base.contains("_driving_policy_tinygrad")) { + has_policy_tg = true; + } else if (base.contains("_driving_vision_metadata")) { + has_vision_meta = true; + } else if (base.contains("_driving_vision_tinygrad")) { + has_vision_tg = true; + } + } + } + + if (has_thneed) { + return true; + } + + if (has_policy_meta && has_policy_tg && has_vision_meta && has_vision_tg) { + return true; + } + + return foundAny; +} + +QMap FrogPilotModelPanel::getDeletableModelDisplayNames() { + QMap deletable; + + QString defaultModelKey = QString::fromStdString(params_default.get("Model")); + QString defaultModelName = modelFileToNameMap.value(defaultModelKey); + QString processedDefault = processModelName(defaultModelName); + QString processedCurrent = processModelName(currentModel); + + for (auto it = modelFileToNameMap.constBegin(); it != modelFileToNameMap.constEnd(); ++it) { + const QString &modelKey = it.key(); + const QString &displayName = it.value(); + if (displayName.isEmpty()) { + continue; + } + + if (!isModelInstalled(modelKey)) { + continue; + } + + QString processedName = processModelName(displayName); + if (!processedCurrent.isEmpty() && processedName == processedCurrent) { + continue; + } + + if (!processedDefault.isEmpty() && processedName == processedDefault) { + continue; + } + + deletable.insert(modelKey, displayName); + } + + return deletable; +} + void FrogPilotModelPanel::showEvent(QShowEvent *event) { FrogPilotUIState &fs = *frogpilotUIState(); UIState &s = *uiState(); @@ -478,6 +571,9 @@ void FrogPilotModelPanel::showEvent(QShowEvent *event) { QStringList availableModels = QString::fromStdString(params.get("AvailableModels")).split(","); availableModelNames = QString::fromStdString(params.get("AvailableModelNames")).split(","); availableModelSeries = QString::fromStdString(params.get("AvailableModelSeries")).split(","); + QStringList releasedDatesParam = QString::fromStdString(params.get("ModelReleasedDates")).split(","); + QStringList communityFavsParam = QString::fromStdString(params.get("CommunityFavorites")).split(","); + QStringList userFavsParam = QString::fromStdString(params.get("UserFavorites")).split(","); // Build a simple model->version map for quick lookups elsewhere { @@ -497,78 +593,54 @@ void FrogPilotModelPanel::showEvent(QShowEvent *event) { modelFileToNameMap.clear(); modelFileToNameMapProcessed.clear(); modelSeriesMap.clear(); - int size = qMin(qMin(availableModels.size(), availableModelNames.size()), availableModelSeries.size()); + modelReleasedDates.clear(); + int size = qMin(availableModels.size(), availableModelNames.size()); for (int i = 0; i < size; ++i) { - modelFileToNameMap.insert(availableModels[i], availableModelNames[i]); - modelFileToNameMapProcessed.insert(availableModels[i], processModelName(availableModelNames[i])); - modelSeriesMap.insert(availableModels[i], availableModelSeries[i]); - } - modelFileToNameMap.insert("space-lab", "Space Lab 👀📡"); - modelFileToNameMapProcessed.insert("space-lab", "Space Lab"); - modelSeriesMap.insert("space-lab", "Dom Forgot To Label Me"); - - auto isInstalled = [this](const QString &key) { - bool has_thneed = false; - bool has_policy_meta = false; - bool has_policy_tg = false; - bool has_vision_meta = false; - bool has_vision_tg = false; - - for (const QString &file : modelDir.entryList(QDir::Files)) { - QFileInfo fi(modelDir.filePath(file)); - const QString base = fi.baseName(); - const QString ext = fi.suffix(); - if (!(base.startsWith(key) || base.startsWith(key + "_"))) continue; - - if (ext == "thneed") { - // Classic model (WD-40 etc.) - has_thneed = true; - } else if (ext == "pkl") { - // TinyGrad bundle uses these four exact suffixes - if (base.contains("_driving_policy_metadata")) has_policy_meta = true; - else if (base.contains("_driving_policy_tinygrad")) has_policy_tg = true; - else if (base.contains("_driving_vision_metadata")) has_vision_meta = true; - else if (base.contains("_driving_vision_tinygrad")) has_vision_tg = true; - } + const QString modelKey = availableModels[i].trimmed(); + const QString modelName = availableModelNames[i].trimmed(); + if (modelKey.isEmpty() || modelName.isEmpty()) { + continue; } - // Classic models: any matching .thneed counts as installed - if (has_thneed) return true; - // TinyGrad models: require all four policy/vision files to be present - return has_policy_meta && has_policy_tg && has_vision_meta && has_vision_tg; - }; - QStringList downloadableModels = availableModelNames; - for (const QString &modelKey : modelFileToNameMap.keys()) { - QString modelName = modelFileToNameMap.value(modelKey); - if (isInstalled(modelKey)) { - downloadableModels.removeAll(modelName); + QString series; + if (i < availableModelSeries.size()) { + series = availableModelSeries[i].trimmed(); + } + if (series.isEmpty()) { + series = tr("Custom Series"); } - } - allModelsDownloaded = downloadableModels.isEmpty(); - QStringList deletableModels; - for (const QString &file : modelDir.entryList(QDir::Files)) { - QString base = QFileInfo(file).baseName(); - for (const QString &modelKey : modelFileToNameMapProcessed.keys()) { - if (base.startsWith(modelKey)) { - QString modelName = modelFileToNameMapProcessed.value(modelKey); - if (!deletableModels.contains(modelName)) { - deletableModels.append(modelName); - } + modelFileToNameMap.insert(modelKey, modelName); + modelFileToNameMapProcessed.insert(modelKey, processModelName(modelName)); + modelSeriesMap.insert(modelKey, series); + + if (i < releasedDatesParam.size()) { + const QString released = releasedDatesParam[i].trimmed(); + if (!released.isEmpty()) { + this->modelReleasedDates.insert(modelKey, released); } } } - deletableModels.removeAll(processModelName(currentModel)); - deletableModels.removeAll(modelFileToNameMapProcessed.value(QString::fromStdString(params_default.get("Model")))); - noModelsDownloaded = deletableModels.isEmpty(); + allModelsDownloaded = true; + for (auto it = modelFileToNameMap.constBegin(); it != modelFileToNameMap.constEnd(); ++it) { + if (it.value().isEmpty()) { + continue; + } + if (!isModelInstalled(it.key())) { + allModelsDownloaded = false; + break; + } + } QString modelKey = QString::fromStdString(params.get("Model")); - if (!isInstalled(modelKey)) { + if (!isModelInstalled(modelKey)) { modelKey = QString::fromStdString(params_default.get("Model")); } currentModel = modelFileToNameMap.value(modelKey); selectModelButton->setValue(currentModel); + noModelsDownloaded = getDeletableModelDisplayNames().isEmpty(); + bool parked = !s.scene.started || fs.frogpilot_scene.parked || fs.frogpilot_toggles.value("frogs_go_moo").toBool(); deleteModelButton->setEnabled(!(allModelsDownloading || modelDownloading || noModelsDownloaded)); @@ -666,9 +738,7 @@ void FrogPilotModelPanel::updateToggles() { if (key == "ManageBlacklistedModels" || key == "ManageScores") { setVisible &= params.getBool("ModelRandomizer"); - } - - else if (key == "SelectModel") { + } else if (key == "SelectModel") { setVisible &= !params.getBool("ModelRandomizer"); } else if (key == "StopDistance") { setVisible &= (tuningLevel == 3); // Only visible in developer tuning level diff --git a/frogpilot/ui/qt/offroad/model_settings.h b/frogpilot/ui/qt/offroad/model_settings.h index e933d4f49..9db95a19e 100644 --- a/frogpilot/ui/qt/offroad/model_settings.h +++ b/frogpilot/ui/qt/offroad/model_settings.h @@ -20,6 +20,8 @@ private: void updateModelLabels(FrogPilotListWidget *labelsList); void updateState(const UIState &s, const FrogPilotUIState &fs); void updateToggles(); + bool isModelInstalled(const QString &key) const; + QMap getDeletableModelDisplayNames(); bool allModelsDownloaded; bool allModelsDownloading; @@ -55,10 +57,12 @@ private: QMap modelFileToNameMap; QMap modelFileToNameMapProcessed; + QMap modelReleasedDates; QMap modelSeriesMap; QString currentModel; + QStringList availableModelNames; QStringList availableModelSeries; };