From 7c1f3f646cd5efdd3077afddfac90c195e1543c2 Mon Sep 17 00:00:00 2001 From: Nayan Date: Sat, 19 Jul 2025 09:52:54 -0400 Subject: [PATCH] UI: Model Folders (#1031) * model folders.. yay * scroll expanded item in view --------- Co-authored-by: DevTekVE --- selfdrive/ui/qt/widgets/input.cc | 139 ++++++++++++++++++ selfdrive/ui/qt/widgets/input.h | 14 ++ .../qt/offroad/settings/models_panel.cc | 73 +++++++-- 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/selfdrive/ui/qt/widgets/input.cc b/selfdrive/ui/qt/widgets/input.cc index 0cbf14931..4f330dca8 100644 --- a/selfdrive/ui/qt/widgets/input.cc +++ b/selfdrive/ui/qt/widgets/input.cc @@ -2,6 +2,7 @@ #include #include +#include #include "system/hardware/hw.h" #include "selfdrive/ui/qt/util.h" @@ -334,3 +335,141 @@ 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) { + QFrame *container = new QFrame(this); + container->setStyleSheet(R"( + QFrame { background-color: #1B1B1B; } + #confirm_btn[enabled="false"] { background-color: #2B2B2B; } + #confirm_btn:enabled { background-color: #465BEA; } + #confirm_btn:enabled:pressed { background-color: #3049F4; } + QTreeWidget { + background-color: transparent; + border: none; + } + QTreeWidget::item { + height: 135; + padding: 0px 50px; + margin: 5px; + text-align: left; + font-size: 55px; + font-weight: 300; + border-radius: 10px; + background-color: #4F4F4F; + color: white; + } + QTreeWidget::item:selected { + background-color: #465BEA; + } + QTreeWidget::branch { + background-color: transparent; + } + )"); + + QVBoxLayout *main_layout = new QVBoxLayout(container); + main_layout->setContentsMargins(55, 50, 55, 50); + + QLabel *title = new QLabel(prompt_text, this); + title->setStyleSheet("font-size: 70px; font-weight: 500;"); + main_layout->addWidget(title, 0, Qt::AlignLeft | Qt::AlignTop); + main_layout->addSpacing(25); + + treeWidget = new QTreeWidget(this); + treeWidget->setHeaderHidden(true); + treeWidget->setIndentation(50); + treeWidget->setExpandsOnDoubleClick(false); // Disable double-click expansion + treeWidget->setAnimated(true); + treeWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + treeWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + treeWidget->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + treeWidget->setDragEnabled(false); + treeWidget->setMouseTracking(true); + + // Connect single-click to expand/collapse + QObject::connect(treeWidget, &QTreeWidget::itemClicked, [=](QTreeWidgetItem *item, int) { + if (item->childCount() > 0) { + item->setExpanded(!item->isExpanded()); + treeWidget->scrollToItem(item->child(0), QAbstractItemView::EnsureVisible); + } + }); + + QScroller::grabGesture(treeWidget->viewport(), QScroller::LeftMouseButtonGesture); + + // Populate tree + QListIterator> iter(items); + while (iter.hasNext()) { + QPair currItem = iter.next(); + if (currItem.first.isEmpty()) { + for (const QString &item : currItem.second) { + QTreeWidgetItem *topLevel = new QTreeWidgetItem(); + topLevel->setText(0, item); + topLevel->setFlags(topLevel->flags() | Qt::ItemIsSelectable); + treeWidget->addTopLevelItem(topLevel); + if (item == current) { + topLevel->setSelected(true); + } + } + } else { + QTreeWidgetItem *folderItem = new QTreeWidgetItem(treeWidget); + folderItem->setIcon(0, QIcon(QPixmap("../assets/icons/menu.png"))); + folderItem->setText(0, " " + currItem.first); + folderItem->setFlags(folderItem->flags() | Qt::ItemIsAutoTristate); + folderItem->setFlags(folderItem->flags() & ~Qt::ItemIsSelectable); + + for (const QString &item : currItem.second) + { + QTreeWidgetItem *childItem = new QTreeWidgetItem(folderItem); + childItem->setText(0, item); + childItem->setFlags(childItem->flags() | Qt::ItemIsSelectable); + + if (item == current) { + childItem->setSelected(true); + folderItem->setExpanded(true); + } + } + } + } + + confirm_btn = new QPushButton(tr("Select")); + confirm_btn->setObjectName("confirm_btn"); + confirm_btn->setEnabled(false); + + QObject::connect(treeWidget, &QTreeWidget::itemSelectionChanged, [=]() { + QList selectedItems = treeWidget->selectedItems(); + if (!selectedItems.isEmpty()) { + selection = selectedItems.first()->text(0); + confirm_btn->setEnabled(selection != current); + } + }); + + ScrollView *scroll_view = new ScrollView(treeWidget, this); + scroll_view->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + + main_layout->addWidget(scroll_view); + main_layout->addSpacing(35); + + // cancel + confirm buttons + QHBoxLayout *blayout = new QHBoxLayout; + main_layout->addLayout(blayout); + blayout->setSpacing(50); + + 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); + blayout->addWidget(cancel_btn); + blayout->addWidget(confirm_btn); + + QVBoxLayout *outer_layout = new QVBoxLayout(this); + outer_layout->setContentsMargins(50, 50, 50, 50); + outer_layout->addWidget(container); +} + +QString TreeOptionDialog::getSelection(const QString &prompt_text, const QList> &items, + const QString ¤t, QWidget *parent) { + TreeOptionDialog d(prompt_text, items, current, parent); + if (d.exec()) { + return d.selection; + } + return ""; +} diff --git a/selfdrive/ui/qt/widgets/input.h b/selfdrive/ui/qt/widgets/input.h index 089e54e4a..76f87bf32 100644 --- a/selfdrive/ui/qt/widgets/input.h +++ b/selfdrive/ui/qt/widgets/input.h @@ -6,6 +6,7 @@ #include #include #include +#include #include "selfdrive/ui/qt/widgets/keyboard.h" @@ -69,3 +70,16 @@ public: static QString getSelection(const QString &prompt_text, const QStringList &l, const QString ¤t, QWidget *parent); QString selection; }; + +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); + QString selection; + +private: + QTreeWidget *treeWidget; + QPushButton *confirm_btn; +}; diff --git a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc index 1a2e86622..90bc80f00 100644 --- a/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc +++ b/selfdrive/ui/sunnypilot/qt/offroad/settings/models_panel.cc @@ -244,28 +244,73 @@ void ModelsPanel::handleCurrentModelLblBtnClicked() { currentModelLblBtn->setEnabled(false); currentModelLblBtn->setValue(tr("Fetching models...")); - // Create mapping of bundle indices to display names - QMap index_to_bundle; + struct ModelEntry { + QString folder; + QString displayName; + int index; + }; + QList sortedModels; + QSet modelFolders; const auto bundles = model_manager.getAvailableBundles(); - for (const auto &bundle: bundles) { - index_to_bundle.insert(bundle.getIndex(), QString::fromStdString(bundle.getDisplayName())); + + for (const auto &bundle : bundles) { + auto overrides = bundle.getOverrides(); + QString gen; + for (const auto &override : overrides) { + if (override.getKey() == "folder") { + gen = QString::fromStdString(override.getValue().cStr()); + } + } + + modelFolders.insert(gen); + sortedModels.append(ModelEntry{ + gen, + QString::fromStdString(bundle.getDisplayName()), + static_cast(bundle.getIndex()) + }); } - // Sort bundles by index in descending order - QStringList bundleNames; - // Add "Default" as the first option - bundleNames.append(DEFAULT_MODEL); + std::sort(sortedModels.begin(), sortedModels.end(), + [](const ModelEntry &a, const ModelEntry &b) { + return a.index > b.index; + }); - auto indices = index_to_bundle.keys(); - std::sort(indices.begin(), indices.end(), std::greater()); - for (const auto &index: indices) { - bundleNames.append(index_to_bundle[index]); + // Create a list of folder-maxIndex pairs for sorting + QList> folderMaxIndices; + for (const auto &folder : modelFolders) { + int maxIndex = -1; + for (const auto &model : sortedModels) { + if (model.folder == folder) { + maxIndex = std::max(maxIndex, model.index); + } + } + folderMaxIndices.append(qMakePair(folder, maxIndex)); } + // Sort folders by their highest model index + std::sort(folderMaxIndices.begin(), folderMaxIndices.end(), + [](const QPair &a, const QPair &b) { + return a.second > b.second; + }); + + // Create the final items list using sorted folders + QList> items; + for (const auto &folderPair : folderMaxIndices) { + QStringList folderModels; + for (const auto &model : sortedModels) { + if (model.folder == folderPair.first) { + folderModels.append(model.displayName); + } + } + items.append(qMakePair(folderPair.first, folderModels)); + } + + items.insert(0, qMakePair(QString(""), QStringList{DEFAULT_MODEL})); + currentModelLblBtn->setValue(GetActiveModelInternalName()); - const QString selectedBundleName = MultiOptionDialog::getSelection( - tr("Select a Model"), bundleNames, GetActiveModelName(), this); + const QString selectedBundleName = TreeOptionDialog::getSelection( + tr("Select a Model"), items, GetActiveModelName(), this); if (selectedBundleName.isEmpty() || !canContinueOnMeteredDialog()) { return;