From 45ee58b1f6bc1ca2462924e9aadf120a9d6edad1 Mon Sep 17 00:00:00 2001 From: Nayan Date: Sun, 31 Aug 2025 02:27:08 -0400 Subject: [PATCH] =?UTF-8?q?ui:=20Favorite=20Models=20=E2=AD=90=20(#1168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init model favorites * fix fav buttons * fix blank favs * switch to ref * new favs at top * remove debug prints & add some comments * button style * fix current selection * !@%#$%(@^%$#(@!%#^ * add last update date to folders --- common/params_keys.h | 1 + selfdrive/ui/qt/widgets/input.cc | 171 ++++++++++++++++-- selfdrive/ui/qt/widgets/input.h | 26 ++- .../qt/offroad/settings/models_panel.cc | 62 ++++--- .../qt/offroad/settings/models_panel.h | 1 + sunnypilot/models/fetcher.py | 1 + .../selfdrive/assets/icons/star-empty.png | 3 + .../selfdrive/assets/icons/star-filled.png | 3 + 8 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 sunnypilot/selfdrive/assets/icons/star-empty.png create mode 100644 sunnypilot/selfdrive/assets/icons/star-filled.png diff --git a/common/params_keys.h b/common/params_keys.h index 01f6f0468..4a78575de 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -167,6 +167,7 @@ inline static std::unordered_map keys = { {"ModelManager_ActiveBundle", {PERSISTENT, JSON}}, {"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}}, {"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT, "0"}}, + {"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}}, {"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}}, {"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}}, diff --git a/selfdrive/ui/qt/widgets/input.cc b/selfdrive/ui/qt/widgets/input.cc index 4f330dca8..efd330587 100644 --- a/selfdrive/ui/qt/widgets/input.cc +++ b/selfdrive/ui/qt/widgets/input.cc @@ -336,8 +336,8 @@ QString MultiOptionDialog::getSelection(const QString &prompt_text, const QStrin return ""; } -TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList> &items, - const QString ¤t, QWidget *parent) : DialogBase(parent) { +TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList &items, + const QString ¤t, const QString &favParam, QWidget *parent) : DialogBase(parent) { QFrame *container = new QFrame(this); container->setStyleSheet(R"( QFrame { background-color: #1B1B1B; } @@ -375,6 +375,9 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListaddWidget(title, 0, Qt::AlignLeft | Qt::AlignTop); main_layout->addSpacing(25); + iconBlank = QIcon("../../sunnypilot/selfdrive/assets/icons/star-empty.png"); + iconFilled = QIcon ("../../sunnypilot/selfdrive/assets/icons/star-filled.png"); + treeWidget = new QTreeWidget(this); treeWidget->setHeaderHidden(true); treeWidget->setIndentation(50); @@ -396,34 +399,49 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListviewport(), QScroller::LeftMouseButtonGesture); + // Create initial list of favorites from param + const QString favs = QString::fromStdString(params.get(favParam.toStdString())); + mapFavs = new QMap>(); + favRefs = new QStringList(favs.split(";")); + for (const QString &item : *favRefs) + { + mapFavs->insert( item, {}); + } + // Populate tree - QListIterator> iter(items); + QListIterator iter(items); while (iter.hasNext()) { - QPair currItem = iter.next(); - if (currItem.first.isEmpty()) { - for (const QString &item : currItem.second) { + TreeFolder currItem = iter.next(); + QString prevFolder; + QString currentFolder; + if (currItem.folder.isEmpty()) { + for (const TreeNode &item : currItem.items) { QTreeWidgetItem *topLevel = new QTreeWidgetItem(); - topLevel->setText(0, item); + topLevel->setText(0, item.displayName); + topLevel->setData(0, Qt::UserRole, item.ref); topLevel->setFlags(topLevel->flags() | Qt::ItemIsSelectable); treeWidget->addTopLevelItem(topLevel); - if (item == current) { + if (item.ref == current) { topLevel->setSelected(true); } } } else { - QTreeWidgetItem *folderItem = new QTreeWidgetItem(treeWidget); + QList folders = treeWidget->findItems(currItem.folder, Qt::MatchExactly, 0); + QTreeWidgetItem *folderItem = nullptr; + if (folders.isEmpty()) { + folderItem = new QTreeWidgetItem(treeWidget); + } else { + folderItem = folders.first(); + } folderItem->setIcon(0, QIcon(QPixmap("../assets/icons/menu.png"))); - folderItem->setText(0, " " + currItem.first); + folderItem->setText(0, " " + currItem.folder); folderItem->setFlags(folderItem->flags() | Qt::ItemIsAutoTristate); folderItem->setFlags(folderItem->flags() & ~Qt::ItemIsSelectable); - for (const QString &item : currItem.second) + for (const TreeNode item : currItem.items) { - QTreeWidgetItem *childItem = new QTreeWidgetItem(folderItem); - childItem->setText(0, item); - childItem->setFlags(childItem->flags() | Qt::ItemIsSelectable); - - if (item == current) { + QTreeWidgetItem *childItem = addChildItem(item.displayName, item.ref, folderItem); + if (item.ref == current) { childItem->setSelected(true); folderItem->setExpanded(true); } @@ -431,6 +449,39 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListsetIcon(0, QIcon(QPixmap("../assets/icons/menu.png"))); + favorites->setText(0, " " + tr("Favorites")); + favorites->setFlags(favorites->flags() | Qt::ItemIsAutoTristate); + favorites->setFlags(favorites->flags() & ~Qt::ItemIsSelectable); + treeWidget->insertTopLevelItem(1, favorites); + + // Create favorite nodes + for (int i = favRefs->size() - 1; i >= 0; --i) { + QString item = favRefs->at(i); + if (item.isEmpty()) continue; + + QTreeWidgetItemIterator treeIt(treeWidget); + QTreeWidgetItem *nodeItem = nullptr; + while (*treeIt) { + if (item == (*treeIt)->data(0, Qt::UserRole).toString()) { + nodeItem = (*treeIt); + break; + } + ++treeIt; + } + if (nodeItem == nullptr) continue; + + QTreeWidgetItem *childItem = addChildItem(nodeItem->text(0), + nodeItem->data(0, Qt::UserRole).toString(), favorites); + if (item == current) { + treeWidget->collapseAll(); + childItem->setSelected(true); + favorites->setExpanded(true); + } + } + confirm_btn = new QPushButton(tr("Select")); confirm_btn->setObjectName("confirm_btn"); confirm_btn->setEnabled(false); @@ -438,7 +489,7 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QList selectedItems = treeWidget->selectedItems(); if (!selectedItems.isEmpty()) { - selection = selectedItems.first()->text(0); + selection = selectedItems.first()->data(0, Qt::UserRole).toString(); confirm_btn->setEnabled(selection != current); } }); @@ -465,11 +516,91 @@ TreeOptionDialog::TreeOptionDialog(const QString &prompt_text, const QListaddWidget(container); } -QString TreeOptionDialog::getSelection(const QString &prompt_text, const QList> &items, - const QString ¤t, QWidget *parent) { - TreeOptionDialog d(prompt_text, items, current, parent); +QString TreeOptionDialog::getSelection(const QString &prompt_text, const QList &items, + const QString ¤t, const QString &favParam, QWidget *parent) { + TreeOptionDialog d(prompt_text, items, current, favParam, parent); if (d.exec()) { return d.selection; } return ""; } + +/** + * Handles the addition or removal of items from the "favorites" list based on the provided reference identifier. + * + * @param displayName The text label associated with the item to be added or removed in the favorites. + * @param ref A unique reference key identifying the item. + * @param btn A pointer to the QPushButton associated with the item. The button's icon is updated to indicate + * whether the item is currently favorited or not. + * + * If the item is already in the favorites, it is removed from the list, its associated buttons have their + * icons reset, and the favorites tree is updated accordingly. If the item is not in the favorites, it is + * added to the list, a new associated button is created, and the favorites tree is updated. The current + * state of the favorites is stored in the Params object as a semicolon-separated string. + */ +void TreeOptionDialog::handleFavorites(const QString &displayName, const QString &ref, QPushButton *btn) { + if (mapFavs->keys().contains(ref)) { // Remove from favorites + for (auto *itemBtn:mapFavs->value(ref)) + { + itemBtn->setIcon(iconBlank); + } + mapFavs->remove(ref); + favRefs->removeAll(ref); + for (int i = 0; i < favorites->childCount(); ++i) { + QTreeWidgetItem* child = favorites->child(i); + if (child && child->data(0, Qt::UserRole).toString() == ref) { + favorites->removeChild(child); + } + } + } else { // Add to favorites + QPushButton *favBtn = new QPushButton(); + btn->setIcon(iconFilled); + mapFavs->insert(ref, {btn, favBtn}); + favRefs->append(ref); + addChildItem(displayName, ref, favorites, favBtn, true); + } + + const QString favs =favRefs->join(";"); + params.put("ModelManager_Favs", favs.toStdString()); +} + +/** + * Adds a child item to a given folder item within the QTreeWidget. + * + * @param displayName The text to display for the child item. + * @param ref A reference string that uniquely identifies the child item. + * @param folderItem The parent folder item to which the child item will be added. + * @param btn A pointer to a QPushButton associated with the child item. If nullptr, a new button will be created. + * @param addAtTop If true, the child item is added as the first child of the folder item; otherwise, it is appended to the end. + * @return A pointer to the created QTreeWidgetItem representing the child item. + */ +QTreeWidgetItem* TreeOptionDialog::addChildItem(const QString &displayName, const QString &ref, QTreeWidgetItem *folderItem, QPushButton *btn, bool addAtTop) { + QTreeWidgetItem *childItem = new QTreeWidgetItem(); + if (btn == nullptr) { + btn = new QPushButton(); + } + if (mapFavs->keys().contains(ref)) { + btn->setIcon(iconFilled); + (*mapFavs)[ref].append(btn); + } else { + btn->setIcon(iconBlank); + } + btn->setIconSize(QSize(100, 100)); + QWidget *buttonContainer = new QWidget(); + QHBoxLayout *layout = new QHBoxLayout(buttonContainer); + layout->addWidget(btn, 0, Qt::AlignRight); + childItem->setText(0, displayName); + childItem->setData(0, Qt::UserRole, ref); + childItem->setFlags(childItem->flags() | Qt::ItemIsSelectable); + if (addAtTop) { + folderItem->insertChild(0, childItem); + } else { + folderItem->addChild(childItem); + } + treeWidget->setItemWidget(childItem, 0, buttonContainer); + + connect(btn, &QPushButton::clicked, btn, [=]() { + handleFavorites(displayName, ref, btn); + }); + return childItem; +} diff --git a/selfdrive/ui/qt/widgets/input.h b/selfdrive/ui/qt/widgets/input.h index 76f87bf32..3fb1ebfe1 100644 --- a/selfdrive/ui/qt/widgets/input.h +++ b/selfdrive/ui/qt/widgets/input.h @@ -8,9 +8,22 @@ #include #include +#include "common/params.h" #include "selfdrive/ui/qt/widgets/keyboard.h" +struct TreeNode { + QString folder; + QString displayName; + QString ref; + int index; +}; + +struct TreeFolder { + QString folder; + QList items; +}; + class DialogBase : public QDialog { Q_OBJECT @@ -75,11 +88,20 @@ class TreeOptionDialog : public DialogBase { Q_OBJECT public: - explicit TreeOptionDialog(const QString &prompt_text, const QList> &items, const QString ¤t, QWidget *parent = nullptr); - static QString getSelection(const QString &prompt_text, const QList> &items, const QString ¤t, QWidget *parent = nullptr); + explicit TreeOptionDialog(const QString &prompt_text, const QList &items, const QString ¤t, const QString &favParam, QWidget *parent = nullptr); + static QString getSelection(const QString &prompt_text, const QList &items, const QString ¤t, const QString &favParam, QWidget *parent = nullptr); + void handleFavorites(const QString &displayName, const QString &ref, QPushButton* btn); + QTreeWidgetItem* addChildItem(const QString &displayName, const QString &ref, QTreeWidgetItem* folderItem, QPushButton* btn = nullptr, bool addAtTop = false); QString selection; private: QTreeWidget *treeWidget; QPushButton *confirm_btn; + Params params; + QMap> *mapFavs; + QStringList *favRefs; + QTreeWidgetItem *favorites; + + QIcon iconBlank; + QIcon iconFilled; }; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc index 93e5ac80a..baba7d3a1 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc @@ -259,6 +259,18 @@ QString ModelsPanel::GetActiveModelInternalName() { return DEFAULT_MODEL; } +/** + * @brief Gets the ref of the currently selected model bundle + * @return ref of the selected bundle or default model name + */ +QString ModelsPanel::GetActiveModelRef() { + if (model_manager.hasActiveBundle()) { + return QString::fromStdString(model_manager.getActiveBundle().getRef()); + } + + return DEFAULT_MODEL; +} + void ModelsPanel::updateModelManagerState() { const SubMaster &sm = *(uiStateSP()->sm); model_manager = sm["modelManagerSP"].getModelManagerSP(); @@ -272,34 +284,31 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { currentModelLblBtn->setEnabled(false); currentModelLblBtn->setValue(tr("Fetching models...")); - struct ModelEntry { - QString folder; - QString displayName; - int index; - }; - QList sortedModels; + QList sortedModels; QSet modelFolders; + QRegularExpression re("\\(([^)]*)\\)[^(]*$"); const auto bundles = model_manager.getAvailableBundles(); for (const auto &bundle : bundles) { auto overrides = bundle.getOverrides(); - QString gen; + QString folder; for (const auto &override : overrides) { if (override.getKey() == "folder") { - gen = QString::fromStdString(override.getValue().cStr()); + folder = QString::fromStdString(override.getValue().cStr()); } } - modelFolders.insert(gen); - sortedModels.append(ModelEntry{ - gen, + modelFolders.insert(folder); + sortedModels.append(TreeNode{ + folder, QString::fromStdString(bundle.getDisplayName()), + QString::fromStdString(bundle.getRef()), static_cast(bundle.getIndex()) }); } std::sort(sortedModels.begin(), sortedModels.end(), - [](const ModelEntry &a, const ModelEntry &b) { + [](const TreeNode &a, const TreeNode &b) { return a.index > b.index; }); @@ -322,37 +331,46 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { }); // Create the final items list using sorted folders - QList> items; + QList items; for (const auto &folderPair : folderMaxIndices) { - QStringList folderModels; + QList folderModels; + QString folder = folderPair.first; for (const auto &model : sortedModels) { if (model.folder == folderPair.first) { - folderModels.append(model.displayName); + if (model.index == folderPair.second) { + QRegularExpressionMatch match = re.match(model.displayName); + if (match.hasMatch()) { + folder.append(" - (Updated: ").append(match.captured(1)).append(")"); + } + } + folderModels.append(model); } } - items.append(qMakePair(folderPair.first, folderModels)); + items.append(TreeFolder{folder, folderModels}); } - items.insert(0, qMakePair(QString(""), QStringList{DEFAULT_MODEL})); + items.insert(0, TreeFolder{"", { + TreeNode{"", DEFAULT_MODEL, DEFAULT_MODEL, -1} + }}); currentModelLblBtn->setValue(GetActiveModelInternalName()); - const QString selectedBundleName = TreeOptionDialog::getSelection( - tr("Select a Model"), items, GetActiveModelName(), this); + const QString selectedBundleRef = TreeOptionDialog::getSelection( + tr("Select a Model"), items, GetActiveModelRef(), QString("ModelManager_Favs"), this); - if (selectedBundleName.isEmpty() || !canContinueOnMeteredDialog()) { + if (selectedBundleRef.isEmpty() || !canContinueOnMeteredDialog()) { return; } // Handle "Stock" selection differently - if (selectedBundleName == DEFAULT_MODEL) { + if (selectedBundleRef == DEFAULT_MODEL) { params.remove("ModelManager_ActiveBundle"); currentModelLblBtn->setValue(tr("Default")); showResetParamsDialog(); } else { // Find selected bundle and initiate download for (const auto &bundle: bundles) { - if (QString::fromStdString(bundle.getDisplayName()) == selectedBundleName) { + if (QString::fromStdString(bundle.getRef()) == selectedBundleRef) { params.put("ModelManager_DownloadIndex", std::to_string(bundle.getIndex())); if (bundle.getGeneration() != model_manager.getActiveBundle().getGeneration()) { showResetParamsDialog(); diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h index 8586d862b..93edc4de1 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.h @@ -20,6 +20,7 @@ public: private: QString GetActiveModelName(); QString GetActiveModelInternalName(); + QString GetActiveModelRef(); void updateModelManagerState(); void showEvent(QShowEvent *event) override; diff --git a/sunnypilot/models/fetcher.py b/sunnypilot/models/fetcher.py index 60a222a36..358c65fe3 100644 --- a/sunnypilot/models/fetcher.py +++ b/sunnypilot/models/fetcher.py @@ -66,6 +66,7 @@ class ModelParser: model_bundle.is20hz = bundle.get("is_20hz", False) model_bundle.minimumSelectorVersion = int(bundle["minimum_selector_version"]) model_bundle.overrides = ModelParser._parse_overrides(bundle.get("overrides", {})) + model_bundle.ref = bundle.get("ref") return model_bundle diff --git a/sunnypilot/selfdrive/assets/icons/star-empty.png b/sunnypilot/selfdrive/assets/icons/star-empty.png new file mode 100644 index 000000000..bf60dec37 --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons/star-empty.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3731604f80e83a1fdb7c258baf6530b81190eeec82e6443172e84a35b7a74c02 +size 1088 diff --git a/sunnypilot/selfdrive/assets/icons/star-filled.png b/sunnypilot/selfdrive/assets/icons/star-filled.png new file mode 100644 index 000000000..3667231bf --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons/star-filled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c2a513b7f2da004f145b7d689654cc65137f1b146d484fbce7ce727a297b62c +size 861