[Settings][Dialog] Implement search for settings by text, description, tooltip, etc. (#7065)

* [Settings] Implement search

Took 38 minutes

Took 8 seconds

Took 9 minutes

Took 5 seconds

Took 44 seconds


Took 25 seconds

* Comments

Took 23 minutes


Took 15 seconds

* Comments

Took 1 hour 14 minutes

* Minor fixes to search

Took 13 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-08 22:19:21 +02:00 committed by GitHub
parent ba203440af
commit bf6b2a90bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1069 additions and 103 deletions

View file

@ -0,0 +1,196 @@
#include "abstract_settings_page.h"
#include "settings_search_model.h"
#include <QCheckBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QPair>
#include <QSpinBox>
/**
* @brief Recursively collects all widgets within a layout
* @param layout The layout to walk
* @param widgets Output list of (widget, containing layout) pairs
*/
static void collectWidgets(QLayout *layout, QList<QPair<QWidget *, QLayout *>> &widgets)
{
for (int i = 0; i < layout->count(); ++i) {
QLayoutItem *item = layout->itemAt(i);
if (!item) {
continue;
}
if (QWidget *widget = item->widget()) {
widgets.append({widget, layout});
} else if (QLayout *subLayout = item->layout()) {
collectWidgets(subLayout, widgets);
}
}
}
/**
* @brief Rejects QLabels that are not setting names
*
* HTML link labels, path values, and excessively long labels are filtered out.
*/
static bool isValidSettingLabel(const QLabel *label)
{
const QString &text = label->text();
if (Qt::mightBeRichText(text)) {
return false;
}
if (text.contains(QLatin1Char('/')) || text.contains(QLatin1Char('\\'))) {
return false;
}
if (text.size() > 60) {
return false;
}
return true;
}
/**
* @brief Finds the control associated with a setting label
*
* Uses the explicit buddy if set, otherwise the widget in the cell (or slot)
* immediately following the label within the same layout. Returns nullptr when
* no obvious control is found.
*/
static QWidget *controlForLabel(QLabel *label, QLayout *containingLayout)
{
if (QWidget *buddy = label->buddy()) {
return buddy;
}
if (auto *grid = qobject_cast<QGridLayout *>(containingLayout)) {
int index = grid->indexOf(label);
if (index != -1) {
int row = 0;
int column = 0;
int rowSpan = 1;
int columnSpan = 1;
grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan);
if (QLayoutItem *next = grid->itemAtPosition(row, column + columnSpan)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
} else {
int index = containingLayout->indexOf(label);
if (index != -1) {
for (int i = index + 1; i < containingLayout->count(); ++i) {
if (QLayoutItem *next = containingLayout->itemAt(i)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
}
}
return nullptr;
}
/**
* @brief Builds the extended search text for an entry
*
* Combines the group title, label, and any extra searchable text derived from
* the associated control (placeholder, prefix/suffix, tooltip). Combo values
* and numeric tooltips are excluded since they change at runtime and would
* make the search index stale.
*/
static QString buildFullSearchText(const QString &groupTitle, const QString &cleanLabel, QWidget *control)
{
QStringList parts = {groupTitle, cleanLabel};
if (control) {
if (auto *lineEdit = qobject_cast<QLineEdit *>(control)) {
parts.append(lineEdit->placeholderText());
} else if (auto *spinBox = qobject_cast<QSpinBox *>(control)) {
parts.append(spinBox->prefix());
parts.append(spinBox->suffix());
}
if (!control->toolTip().isEmpty()) {
bool isNumeric = false;
control->toolTip().toInt(&isNumeric);
if (!isNumeric) {
parts.append(control->toolTip());
}
}
}
parts.removeAll(QString());
return parts.join(QLatin1Char(' '));
}
QList<SettingsSearchEntry> AbstractSettingsPage::getSearchEntries()
{
return autoDetectSearchEntries(this, -1);
}
QList<SettingsSearchEntry> AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex)
{
QList<SettingsSearchEntry> entries;
const auto children = page->children();
for (QObject *child : children) {
auto *groupBox = qobject_cast<QGroupBox *>(child);
if (!groupBox) {
continue;
}
QString groupTitle = groupBox->title();
if (groupTitle.isEmpty()) {
continue;
}
QLayout *groupLayout = groupBox->layout();
if (!groupLayout) {
continue;
}
QList<QPair<QWidget *, QLayout *>> widgets;
collectWidgets(groupLayout, widgets);
for (const auto &pair : widgets) {
QWidget *widget = pair.first;
QString label;
auto *checkBox = qobject_cast<QCheckBox *>(widget);
if (checkBox) {
label = checkBox->text();
} else {
auto *labelWidget = qobject_cast<QLabel *>(widget);
if (!labelWidget || labelWidget->text().isEmpty() || !isValidSettingLabel(labelWidget)) {
continue;
}
label = labelWidget->text();
}
if (label.isEmpty()) {
continue;
}
// Strip accelerator markers (&) for search
QString cleanLabel = label;
cleanLabel.remove(QLatin1Char('&'));
QWidget *control = widget;
if (auto *labelWidget = qobject_cast<QLabel *>(widget)) {
control = controlForLabel(labelWidget, pair.second);
if (!control) {
continue;
}
}
entries.append(SettingsSearchEntry{.pageIndex = pageIndex,
.groupTitle = groupTitle,
.widgetLabel = cleanLabel,
.fullSearchText = buildFullSearchText(groupTitle, cleanLabel, control),
.widget = control});
}
}
return entries;
}

View file

@ -1,16 +1,24 @@
#ifndef COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#include <QList>
#include <QWidget>
#define WIKI_CUSTOM_PIC_URL "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs"
#define WIKI_CUSTOM_SHORTCUTS "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Keyboard-Shortcuts"
#define WIKI_TRANSLATION_FAQ "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
struct SettingsSearchEntry;
class AbstractSettingsPage : public QWidget
{
Q_OBJECT
public:
virtual void retranslateUi() = 0;
virtual QList<SettingsSearchEntry> getSearchEntries();
protected:
static QList<SettingsSearchEntry> autoDetectSearchEntries(QWidget *page, int pageIndex);
};
#endif // COCKATRICE_ABSTRACT_SETTINGS_PAGE_H

View file

@ -90,6 +90,7 @@ MessagesSettingsPage::MessagesSettingsPage()
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
highlightNotice->addWidget(customAlertString, 0, 0);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0);
customAlertStringLabel.setBuddy(customAlertString);
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);

View file

@ -0,0 +1,125 @@
/**
* @file settings_search_delegate.cpp
* @brief Implementation of the custom settings search result delegate
* @ingroup Dialogs
*/
#include "settings_search_delegate.h"
#include "settings_search_model.h"
#include <QPainter>
SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
}
void SettingsSearchDelegate::setPageNames(const QStringList &names)
{
pageNames = names;
}
void SettingsSearchDelegate::setPageIcons(const QStringList &iconResources)
{
pageIcons.clear();
for (const QString &resource : iconResources) {
pageIcons.append(QPixmap(resource));
}
}
void SettingsSearchDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
painter->save();
SettingsSearchEntry entry = index.data(SettingsSearchModel::EntryRole).value<SettingsSearchEntry>();
bool isSelected = option.state & QStyle::State_Selected;
bool isHovered = option.state & QStyle::State_MouseOver;
// Background
QColor bgColor = isSelected ? option.palette.color(QPalette::Highlight)
: isHovered ? option.palette.color(QPalette::Midlight)
: option.palette.color(QPalette::Base);
painter->fillRect(option.rect, bgColor);
if (isSelected) {
// Accent bar on the left to make the selection unmistakable
painter->fillRect(QRect(option.rect.left(), option.rect.top(), 4, option.rect.height()),
option.palette.color(QPalette::Highlight).darker(150));
}
int leftMargin = 12;
int topMargin = 8;
int rightMargin = 12;
int bottomMargin = 4;
QRect contentRect = option.rect.adjusted(leftMargin, topMargin, -rightMargin, -bottomMargin);
int yPos = contentRect.top();
// Icon of the related settings page
const int iconSize = 24;
QPixmap pageIcon =
(entry.pageIndex >= 0 && entry.pageIndex < pageIcons.size()) ? pageIcons.at(entry.pageIndex) : QPixmap();
int iconOffset = pageIcon.isNull() ? 0 : iconSize + 8;
if (!pageIcon.isNull()) {
QRect iconRect(contentRect.left(), contentRect.top() + (contentRect.height() - iconSize) / 2, iconSize,
iconSize);
painter->drawPixmap(iconRect, pageIcon);
}
QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0);
// Breadcrumb: "Page > Group"
QFont breadcrumbFont = option.font;
breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1);
breadcrumbFont.setBold(true);
QColor breadcrumbColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
if (!isSelected) {
breadcrumbColor.setAlpha(180);
}
QString pageName;
if (entry.pageIndex >= 0 && entry.pageIndex < pageNames.size()) {
pageName = pageNames[entry.pageIndex];
} else {
pageName = QString::number(entry.pageIndex);
}
QString breadcrumbText = QStringLiteral("%1 > %2").arg(pageName, entry.groupTitle);
painter->setFont(breadcrumbFont);
painter->setPen(breadcrumbColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter,
breadcrumbText);
yPos += 20;
// Setting label
QFont labelFont = option.font;
labelFont.setPointSize(labelFont.pointSize() + 1);
labelFont.setBold(isSelected);
QColor labelColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
painter->setFont(labelFont);
painter->setPen(labelColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 24), Qt::AlignLeft | Qt::AlignVCenter,
entry.widgetLabel);
yPos += 24;
// Bottom separator
QPen separatorPen(option.palette.color(QPalette::Mid), 1);
painter->setPen(separatorPen);
painter->drawLine(option.rect.left() + leftMargin, option.rect.bottom(), option.rect.right() - rightMargin,
option.rect.bottom());
painter->restore();
}
QSize SettingsSearchDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Q_UNUSED(index);
return QSize(option.rect.width(), 56);
}

View file

@ -0,0 +1,38 @@
/**
* @file settings_search_delegate.h
* @brief Custom delegate for rendering settings search results
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#include <QPixmap>
#include <QStyledItemDelegate>
/**
* @brief Custom paint delegate for settings search result items
*
* Renders each search result with a breadcrumb line ("Page > Group"),
* the setting label, and a subtle separator. Supports selected/hovered states.
*/
class SettingsSearchDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit SettingsSearchDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
/** @brief Sets the translated page names for breadcrumb display */
void setPageNames(const QStringList &names);
/** @brief Sets the icons shown in front of results, indexed by page position */
void setPageIcons(const QStringList &iconResources);
private:
QStringList pageNames; ///< Translated page names indexed by page position
QList<QPixmap> pageIcons; ///< Icons of the related settings pages
};
#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H

View file

@ -0,0 +1,145 @@
/**
* @file settings_search_model.cpp
* @brief Implementation of the settings search list model
* @ingroup Dialogs
*/
#include "settings_search_model.h"
#include <QPair>
#include <algorithm>
SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent)
{
}
void SettingsSearchModel::setSourceEntries(const QList<SettingsSearchEntry> &entries)
{
beginResetModel();
sourceEntries = entries;
endResetModel();
rebuildFilter();
}
void SettingsSearchModel::setFilterString(const QString &text)
{
filterActive = !text.trimmed().isEmpty();
if (filterActive) {
filterQuery = text.trimmed();
filterRegex =
QRegularExpression(QRegularExpression::escape(filterQuery), QRegularExpression::CaseInsensitiveOption);
}
rebuildFilter();
}
bool SettingsSearchModel::isFilterActive() const
{
return filterActive;
}
/**
* @brief Calculates a relevance score for a single entry against the query
*
* Scoring priorities (highest to lowest):
* 1. Label starts with query -> 100
* 2. Label contains query -> 80
* 3. Group title starts with query -> 60
* 4. Group title contains query -> 40
* 5. Full text regex match -> 20
* 6. No match -> 0 (excluded from results)
*/
static int relevanceScore(const SettingsSearchEntry &entry, const QString &query, const QRegularExpression &regex)
{
QString lowerQuery = query.toLower();
// Label matches are most relevant
QString label = entry.widgetLabel.toLower();
if (label.startsWith(lowerQuery)) {
return 100;
}
if (label.contains(lowerQuery)) {
return 80;
}
// Group title matches are next
QString group = entry.groupTitle.toLower();
if (group.startsWith(lowerQuery)) {
return 60;
}
if (group.contains(lowerQuery)) {
return 40;
}
// Full text match is least relevant
if (entry.fullSearchText.contains(regex)) {
return 20;
}
return 0;
}
void SettingsSearchModel::rebuildFilter()
{
beginResetModel();
filteredIndices.clear();
if (!filterActive) {
for (int i = 0; i < sourceEntries.size(); ++i) {
filteredIndices.append(i);
}
} else {
QList<QPair<int, int>> scored; // <score, index>
for (int i = 0; i < sourceEntries.size(); ++i) {
const SettingsSearchEntry &entry = sourceEntries[i];
// Skip conditional settings that are currently disabled or hidden
if (entry.widget && (!entry.widget->isEnabled() || entry.widget->isHidden())) {
continue;
}
int score = relevanceScore(entry, filterQuery, filterRegex);
if (score > 0) {
scored.append({-score, i}); // negative for descending sort
}
}
std::sort(scored.begin(), scored.end());
for (const auto &pair : scored) {
filteredIndices.append(pair.second);
}
}
endResetModel();
}
int SettingsSearchModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return filteredIndices.size();
}
QVariant SettingsSearchModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
const SettingsSearchEntry &entry = sourceEntries[filteredIndices[index.row()]];
switch (role) {
case EntryRole:
return QVariant::fromValue(entry);
case Qt::DisplayRole:
return entry.widgetLabel;
case Qt::ToolTipRole:
return QStringLiteral("%1 > %2 > %3")
.arg(QString::number(entry.pageIndex), entry.groupTitle, entry.widgetLabel);
default:
return {};
}
}
SettingsSearchEntry SettingsSearchModel::entryForIndex(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
return sourceEntries[filteredIndices[index.row()]];
}

View file

@ -0,0 +1,76 @@
/**
* @file settings_search_model.h
* @brief Data model for the settings search feature
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_MODEL_H
#define COCKATRICE_SETTINGS_SEARCH_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QRegularExpression>
#include <QWidget>
/**
* @brief Represents a single searchable setting entry
*
* Each settings page provides a list of these entries via getSearchEntries().
* The model uses them for filtering, relevance scoring, and display.
*/
struct SettingsSearchEntry
{
int pageIndex; ///< Index of the settings page this entry belongs to
QString groupTitle; ///< Title of the group/section within the page
QString widgetLabel; ///< Display label for the setting widget
QString fullSearchText; ///< Extended search text (label, control text, tooltip) for full-text matching
QWidget *widget; ///< Pointer to the setting widget for focus/scrolling
};
/**
* @brief List model providing filtered, ranked search results
*
* Manages a list of SettingsSearchEntry items. When a filter string is set,
* entries are scored by relevance and sorted so the best matches appear first.
* Supports custom roles for accessing entry fields from views and delegates.
*/
class SettingsSearchModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Custom data role for accessing the full entry
*/
enum Roles
{
EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object
};
explicit SettingsSearchModel(QObject *parent = nullptr);
/** @brief Replaces the source entries and rebuilds the filter */
void setSourceEntries(const QList<SettingsSearchEntry> &entries);
/** @brief Sets the filter string and recalculates the results */
void setFilterString(const QString &text);
/** @brief Whether a non-empty filter is currently active */
bool isFilterActive() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/** @brief Returns the full entry for a given model index */
SettingsSearchEntry entryForIndex(const QModelIndex &index) const;
private:
QList<SettingsSearchEntry> sourceEntries; ///< Complete unfiltered entry list
QList<int> filteredIndices; ///< Indices into sourceEntries matching the filter
QRegularExpression filterRegex; ///< Compiled regex for the current filter
QString filterQuery; ///< Current filter query string
bool filterActive = false; ///< Whether filtering is active
/** @brief Recalculates the filtered index list and ranking */
void rebuildFilter();
};
Q_DECLARE_METATYPE(SettingsSearchEntry)
#endif // COCKATRICE_SETTINGS_SEARCH_MODEL_H

View file

@ -123,6 +123,7 @@ void ShortcutSettingsPage::retranslateUi()
currentActionGroupLabel->setText(tr("Section:"));
currentActionLabel->setText(tr("Action:"));
currentShortcutLabel->setText(tr("Shortcut:"));
editShortcutGroupBox->setTitle(tr("Shortcut editor"));
editTextBox->retranslateUi();
faqLabel->setText(QString("<a href='%1'>%2</a>").arg(WIKI_CUSTOM_SHORTCUTS).arg(tr("How to set custom shortcuts")));
btnResetAll->setText(tr("Restore all default shortcuts"));