diff --git a/src/Gui/Dialogs/DlgPreferences.ui b/src/Gui/Dialogs/DlgPreferences.ui
index 1f169e871c..14bbef221a 100644
--- a/src/Gui/Dialogs/DlgPreferences.ui
+++ b/src/Gui/Dialogs/DlgPreferences.ui
@@ -210,6 +210,35 @@
+ -
+
+
+ 4
+
+
-
+
+
+
+ 200
+ 0
+
+
+
+
+ 200
+ 16777215
+
+
+
+ Search preferences...
+
+
+ true
+
+
+
+
+
-
diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp
index d5b539994e..75d9b1289d 100644
--- a/src/Gui/Dialogs/DlgPreferencesImp.cpp
+++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp
@@ -28,18 +28,35 @@
# include
# include
# include
+# include
# include
# include
+# include
+# include
+# include
+# include
+# include
+# include
# include
# include
+# include
# include
# include
# include
+# include
# include
+# include
# include
# include
# include
# include
+# include
+# include
+# include
+# include
+# include
+# include
+# include
#endif
#include
@@ -56,6 +73,154 @@
using namespace Gui::Dialog;
+// Simple delegate to render first line bold, second line normal
+// used by search box
+class MixedFontDelegate : public QStyledItemDelegate
+{
+ static constexpr int horizontalPadding = 12;
+ static constexpr int verticalPadding = 4;
+
+public:
+ explicit MixedFontDelegate(QObject* parent = nullptr) : QStyledItemDelegate(parent) {}
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
+ {
+ if (!index.isValid()) {
+ QStyledItemDelegate::paint(painter, option, index);
+ return;
+ }
+
+ QString pathText, widgetText;
+ extractTextData(index, pathText, widgetText);
+
+ if (pathText.isEmpty()) {
+ QStyledItemDelegate::paint(painter, option, index);
+ return;
+ }
+
+ QFont boldFont, normalFont;
+ createFonts(option.font, boldFont, normalFont);
+
+ LayoutInfo layout = calculateLayout(pathText, widgetText, boldFont, normalFont, option.rect.width());
+
+ painter->save();
+
+ // draw selection background if selected
+ if (option.state & QStyle::State_Selected) {
+ painter->fillRect(option.rect, option.palette.highlight());
+ }
+
+ // Set text color based on selection
+ QColor textColor = (option.state & QStyle::State_Selected)
+ ? option.palette.highlightedText().color()
+ : option.palette.text().color();
+ painter->setPen(textColor);
+
+ // draw path in bold (Tab/Page) with wrapping
+ painter->setFont(boldFont);
+ QRect boldRect(option.rect.left() + horizontalPadding, option.rect.top() + verticalPadding,
+ layout.availableWidth, layout.pathHeight);
+ painter->drawText(boldRect, Qt::TextWordWrap | Qt::AlignTop, pathText);
+
+ // draw widget text in normal font (if present)
+ if (!widgetText.isEmpty()) {
+ painter->setFont(normalFont);
+ QRect normalRect(option.rect.left() + horizontalPadding,
+ option.rect.top() + verticalPadding + layout.pathHeight,
+ layout.availableWidth,
+ layout.widgetHeight);
+ painter->drawText(normalRect, Qt::TextWordWrap | Qt::AlignTop, widgetText);
+ }
+
+ painter->restore();
+ }
+
+ QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override
+ {
+ if (!index.isValid()) {
+ return QStyledItemDelegate::sizeHint(option, index);
+ }
+
+ QString pathText, widgetText;
+ extractTextData(index, pathText, widgetText);
+
+ if (pathText.isEmpty()) {
+ return QStyledItemDelegate::sizeHint(option, index);
+ }
+
+ QFont boldFont, normalFont;
+ createFonts(option.font, boldFont, normalFont);
+
+ LayoutInfo layout = calculateLayout(pathText, widgetText, boldFont, normalFont, option.rect.width());
+
+ return {layout.totalWidth, layout.totalHeight};
+ }
+
+private:
+ struct LayoutInfo {
+ int availableWidth;
+ int pathHeight;
+ int widgetHeight;
+ int totalWidth;
+ int totalHeight;
+ };
+
+ void extractTextData(const QModelIndex& index, QString& pathText, QString& widgetText) const
+ {
+ // Use separate roles - all items should have proper role data
+ pathText = index.data(PreferencesSearchController::PathRole).toString();
+ widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString();
+ }
+
+ void createFonts(const QFont& baseFont, QFont& boldFont, QFont& normalFont) const
+ {
+ boldFont = baseFont;
+ boldFont.setBold(true);
+ boldFont.setPointSize(boldFont.pointSize() - 1); // make header smaller like a subtitle
+
+ normalFont = baseFont; // keep widget text at normal size
+ }
+
+ LayoutInfo calculateLayout(const QString& pathText, const QString& widgetText,
+ const QFont& boldFont, const QFont& normalFont, int containerWidth) const
+ {
+
+ QFontMetrics boldFm(boldFont);
+ QFontMetrics normalFm(normalFont);
+
+ int availableWidth = containerWidth - horizontalPadding * 2; // account for left and right padding
+ if (availableWidth <= 0) {
+ constexpr int defaultPopupWidth = 300;
+ availableWidth = defaultPopupWidth - horizontalPadding * 2; // Fallback to popup width minus padding
+ }
+
+ // Calculate dimensions for path text (bold)
+ QRect pathBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, pathText);
+ int pathHeight = pathBoundingRect.height();
+ int pathWidth = pathBoundingRect.width();
+
+ // Calculate dimensions for widget text (normal font, if present)
+ int widgetHeight = 0;
+ int widgetWidth = 0;
+ if (!widgetText.isEmpty()) {
+ QRect widgetBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText);
+ widgetHeight = widgetBoundingRect.height();
+ widgetWidth = widgetBoundingRect.width();
+ }
+
+ int totalWidth = qMax(pathWidth, widgetWidth) + horizontalPadding * 2; // +24 horizontal padding
+ int totalHeight = verticalPadding * 2 + pathHeight + widgetHeight; // 8 vertical padding + content heights
+
+ LayoutInfo layout;
+ layout.availableWidth = availableWidth;
+ layout.pathHeight = pathHeight;
+ layout.widgetHeight = widgetHeight;
+ layout.totalWidth = totalWidth;
+ layout.totalHeight = totalHeight;
+ return layout;
+ }
+};
+
bool isParentOf(const QModelIndex& parent, const QModelIndex& child)
{
for (auto it = child; it.isValid(); it = it.parent()) {
@@ -133,9 +298,17 @@ DlgPreferencesImp::DlgPreferencesImp(QWidget* parent, Qt::WindowFlags fl)
// remove unused help button
setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+ // Initialize search controller
+ m_searchController = std::make_unique(this, this);
+
setupConnections();
ui->groupsTreeView->setModel(&_model);
+
+ // Configure search controller after UI setup
+ m_searchController->setPreferencesModel(&_model);
+ m_searchController->setGroupNameRole(GroupNameRole);
+ m_searchController->setPageNameRole(PageNameRole);
setupPages();
@@ -150,6 +323,9 @@ DlgPreferencesImp::DlgPreferencesImp(QWidget* parent, Qt::WindowFlags fl)
*/
DlgPreferencesImp::~DlgPreferencesImp()
{
+ // Remove global event filter
+ qApp->removeEventFilter(this);
+
if (DlgPreferencesImp::_activeDialog == this) {
DlgPreferencesImp::_activeDialog = nullptr;
}
@@ -185,6 +361,23 @@ void DlgPreferencesImp::setupConnections()
&QStackedWidget::currentChanged,
this,
&DlgPreferencesImp::onStackWidgetChange);
+ // Connect search functionality to controller
+ connect(ui->searchBox,
+ &QLineEdit::textChanged,
+ m_searchController.get(),
+ &PreferencesSearchController::onSearchTextChanged);
+
+ // Connect navigation signal from controller to dialog
+ connect(m_searchController.get(),
+ &PreferencesSearchController::navigationRequested,
+ this,
+ &DlgPreferencesImp::onNavigationRequested);
+
+ // Install event filter on search box for arrow key navigation
+ ui->searchBox->installEventFilter(this);
+
+ // Install global event filter to handle clicks outside popup
+ qApp->installEventFilter(this);
}
void DlgPreferencesImp::setupPages()
@@ -903,6 +1096,45 @@ void DlgPreferencesImp::onStackWidgetChange(int index)
ui->groupsTreeView->selectionModel()->select(currentIndex, QItemSelectionModel::ClearAndSelect);
}
+void DlgPreferencesImp::onNavigationRequested(const QString& groupName, const QString& pageName)
+{
+ navigateToSearchResult(groupName, pageName);
+}
+
+void DlgPreferencesImp::navigateToSearchResult(const QString& groupName, const QString& pageName)
+{
+ // Find the group and page items
+ auto root = _model.invisibleRootItem();
+ for (int i = 0; i < root->rowCount(); i++) {
+ auto groupItem = static_cast(root->child(i));
+ if (groupItem->data(GroupNameRole).toString() == groupName) {
+
+ // Find the specific page
+ for (int j = 0; j < groupItem->rowCount(); j++) {
+ auto pageItem = static_cast(groupItem->child(j));
+ if (pageItem->data(PageNameRole).toString() == pageName) {
+
+ // Expand the group if needed
+ ui->groupsTreeView->expand(groupItem->index());
+
+ // Select the page
+ ui->groupsTreeView->selectionModel()->select(pageItem->index(), QItemSelectionModel::ClearAndSelect);
+
+ // Navigate to the page
+ onPageSelected(pageItem->index());
+
+ return;
+ }
+ }
+
+ // If no specific page found, just navigate to the group
+ ui->groupsTreeView->selectionModel()->select(groupItem->index(), QItemSelectionModel::ClearAndSelect);
+ onPageSelected(groupItem->index());
+ return;
+ }
+ }
+}
+
void DlgPreferencesImp::changeEvent(QEvent *e)
{
if (e->type() == QEvent::LanguageChange) {
@@ -1007,4 +1239,719 @@ PreferencesPageItem* DlgPreferencesImp::getCurrentPage() const
return pageWidget->property(PreferencesPageItem::PropertyName).value();
}
+// PreferencesSearchController implementation
+PreferencesSearchController::PreferencesSearchController(DlgPreferencesImp* parentDialog, QObject* parent)
+ : QObject(parent)
+ , m_parentDialog(parentDialog)
+ , m_preferencesModel(nullptr)
+ , m_groupNameRole(0)
+ , m_pageNameRole(0)
+ , m_searchBox(nullptr)
+ , m_searchResultsList(nullptr)
+{
+ // Get reference to search box from parent dialog's UI
+ m_searchBox = m_parentDialog->ui->searchBox;
+
+ // Create the search results popup list
+ m_searchResultsList = new QListWidget(m_parentDialog);
+ m_searchResultsList->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::X11BypassWindowManagerHint);
+ m_searchResultsList->setVisible(false);
+ m_searchResultsList->setMinimumWidth(300);
+ m_searchResultsList->setMaximumHeight(400); // Increased max height
+ m_searchResultsList->setFrameStyle(QFrame::Box | QFrame::Raised);
+ m_searchResultsList->setLineWidth(1);
+ m_searchResultsList->setFocusPolicy(Qt::NoFocus); // Don't steal focus from search box
+ m_searchResultsList->setAttribute(Qt::WA_ShowWithoutActivating); // Show without activating/stealing focus
+ m_searchResultsList->setWordWrap(true); // Enable word wrapping
+ m_searchResultsList->setTextElideMode(Qt::ElideNone); // Don't elide text, let it wrap instead
+ m_searchResultsList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Disable horizontal scrollbar
+ m_searchResultsList->setSpacing(0); // Remove spacing between items
+ m_searchResultsList->setContentsMargins(0, 0, 0, 0); // Remove margins
+
+ // Set custom delegate for mixed font rendering (bold first line, normal second line)
+ m_searchResultsList->setItemDelegate(new MixedFontDelegate(m_searchResultsList));
+
+ // Connect search results list signals
+ connect(m_searchResultsList,
+ &QListWidget::itemSelectionChanged,
+ this,
+ &PreferencesSearchController::onSearchResultSelected);
+ connect(m_searchResultsList,
+ &QListWidget::itemDoubleClicked,
+ this,
+ &PreferencesSearchController::onSearchResultDoubleClicked);
+ connect(m_searchResultsList,
+ &QListWidget::itemClicked,
+ this,
+ &PreferencesSearchController::onSearchResultClicked);
+
+ // Install event filter for keyboard navigation in search results
+ m_searchResultsList->installEventFilter(m_parentDialog);
+}
+
+void PreferencesSearchController::setPreferencesModel(QStandardItemModel* model)
+{
+ m_preferencesModel = model;
+}
+
+void PreferencesSearchController::setGroupNameRole(int role)
+{
+ m_groupNameRole = role;
+}
+
+void PreferencesSearchController::setPageNameRole(int role)
+{
+ m_pageNameRole = role;
+}
+
+QListWidget* PreferencesSearchController::getSearchResultsList() const
+{
+ return m_searchResultsList;
+}
+
+bool PreferencesSearchController::isPopupVisible() const
+{
+ return m_searchResultsList && m_searchResultsList->isVisible();
+}
+
+bool PreferencesSearchController::isPopupUnderMouse() const
+{
+ return m_searchResultsList && m_searchResultsList->underMouse();
+}
+
+bool PreferencesSearchController::isPopupAncestorOf(QWidget* widget) const
+{
+ return m_searchResultsList && m_searchResultsList->isAncestorOf(widget);
+}
+
+void PreferencesSearchController::onSearchTextChanged(const QString& text)
+{
+ if (text.isEmpty()) {
+ clearHighlights();
+ m_searchResults.clear();
+ m_lastSearchText.clear();
+ hideSearchResultsList();
+ return;
+ }
+
+ // Only perform new search if text changed
+ if (text != m_lastSearchText) {
+ performSearch(text);
+ m_lastSearchText = text;
+ }
+}
+
+void PreferencesSearchController::performSearch(const QString& searchText)
+{
+ clearHighlights();
+ m_searchResults.clear();
+
+ if (searchText.length() < 2) {
+ hideSearchResultsList();
+ return;
+ }
+
+ // Search through all groups and pages to collect ALL results
+ auto root = m_preferencesModel->invisibleRootItem();
+ for (int i = 0; i < root->rowCount(); i++) {
+ auto groupItem = static_cast(root->child(i));
+ auto groupName = groupItem->data(m_groupNameRole).toString();
+ auto groupStack = qobject_cast(groupItem->getWidget());
+
+ if (!groupStack) {
+ continue;
+ }
+
+ // Search in each page of the group
+ for (int j = 0; j < groupItem->rowCount(); j++) {
+ auto pageItem = static_cast(groupItem->child(j));
+ auto pageName = pageItem->data(m_pageNameRole).toString();
+ auto pageWidget = qobject_cast(pageItem->getWidget());
+
+ if (!pageWidget) {
+ continue;
+ }
+
+ // Collect all matching widgets in this page
+ collectSearchResults(pageWidget, searchText, groupName, pageName, pageItem->text(), groupItem->text());
+ }
+ }
+
+ // Sort results by score (highest first)
+ std::sort(m_searchResults.begin(), m_searchResults.end(),
+ [](const SearchResult& a, const SearchResult& b) {
+ return a.score > b.score;
+ });
+
+ // Update UI with search results
+ if (!m_searchResults.isEmpty()) {
+ populateSearchResultsList();
+ showSearchResultsList();
+ } else {
+ hideSearchResultsList();
+ }
+}
+
+void PreferencesSearchController::clearHighlights()
+{
+ // Restore original styles for all highlighted widgets
+ for (int i = 0; i < m_highlightedWidgets.size(); ++i) {
+ QWidget* widget = m_highlightedWidgets.at(i);
+ if (widget && m_originalStyles.contains(widget)) {
+ widget->setStyleSheet(m_originalStyles[widget]);
+ }
+ }
+ m_highlightedWidgets.clear();
+ m_originalStyles.clear();
+}
+
+void PreferencesSearchController::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName,
+ const QString& pageName, const QString& pageDisplayName, const QString& tabName)
+{
+ if (!widget) {
+ return;
+ }
+
+ const QString lowerSearchText = searchText.toLower();
+
+ // First, check if the page display name itself matches (highest priority)
+ int pageScore = 0;
+ if (fuzzyMatch(searchText, pageDisplayName, pageScore)) {
+ SearchResult result
+ {
+ .groupName = groupName,
+ .pageName = pageName,
+ .widget = widget, // Use the page widget itself
+ .matchText = pageDisplayName, // Use display name, not internal name
+ .groupBoxName = QString(), // No groupbox for page-level match
+ .tabName = tabName,
+ .pageDisplayName = pageDisplayName,
+ .isPageLevelMatch = true, // Mark as page-level match
+ .score = pageScore + 2000 // Boost page-level matches
+ };
+ m_searchResults.append(result);
+ // Continue searching for individual items even if page matches
+ }
+
+ // Search different widget types using the template method
+ searchWidgetType(widget, searchText, groupName, pageName, pageDisplayName, tabName);
+ searchWidgetType(widget, searchText, groupName, pageName, pageDisplayName, tabName);
+ searchWidgetType(widget, searchText, groupName, pageName, pageDisplayName, tabName);
+ searchWidgetType(widget, searchText, groupName, pageName, pageDisplayName, tabName);
+}
+
+void PreferencesSearchController::onSearchResultSelected()
+{
+ // This method is called when a search result is selected (arrow keys or single click)
+ // Navigate immediately but keep popup open
+ if (m_searchResultsList && m_searchResultsList->currentItem()) {
+ navigateToCurrentSearchResult(PopupAction::KeepOpen);
+ }
+
+ ensureSearchBoxFocus();
+}
+
+void PreferencesSearchController::onSearchResultClicked()
+{
+ // Handle single click - navigate immediately but keep popup open
+ if (m_searchResultsList && m_searchResultsList->currentItem()) {
+ navigateToCurrentSearchResult(PopupAction::KeepOpen);
+ }
+
+ ensureSearchBoxFocus();
+}
+
+void PreferencesSearchController::onSearchResultDoubleClicked()
+{
+ // Handle double click - navigate and close popup
+ if (m_searchResultsList && m_searchResultsList->currentItem()) {
+ navigateToCurrentSearchResult(PopupAction::CloseAfter);
+ }
+}
+
+void PreferencesSearchController::navigateToCurrentSearchResult(PopupAction action)
+{
+ QListWidgetItem* currentItem = m_searchResultsList->currentItem();
+
+ // Skip if it's a separator (non-selectable item) or no item selected
+ if (!currentItem || !(currentItem->flags() & Qt::ItemIsSelectable)) {
+ return;
+ }
+
+ // Get the result index directly from the item data
+ bool ok;
+ int resultIndex = currentItem->data(Qt::UserRole).toInt(&ok);
+
+ if (ok && resultIndex >= 0 && resultIndex < m_searchResults.size()) {
+ const SearchResult& result = m_searchResults.at(resultIndex);
+
+ // Emit signal to request navigation
+ Q_EMIT navigationRequested(result.groupName, result.pageName);
+
+ // Clear any existing highlights
+ clearHighlights();
+
+ // Only highlight specific widgets for non-page-level matches
+ if (!result.isPageLevelMatch && !result.widget.isNull()) {
+ applyHighlightToWidget(result.widget);
+ }
+ // For page-level matches, we just navigate without highlighting anything
+
+ // Close popup only if requested (double-click or Enter)
+ if (action == PopupAction::CloseAfter) {
+ hideSearchResultsList();
+ }
+ }
+}
+
+void PreferencesSearchController::populateSearchResultsList()
+{
+ m_searchResultsList->clear();
+
+ for (int i = 0; i < m_searchResults.size(); ++i) {
+ const SearchResult& result = m_searchResults.at(i);
+
+ // Create item without setting DisplayRole
+ QListWidgetItem* item = new QListWidgetItem();
+
+ // Store path and widget text in separate roles
+ if (result.isPageLevelMatch) {
+ // For page matches: parent group as header, page name as content
+ item->setData(PathRole, result.tabName);
+ item->setData(WidgetTextRole, result.pageDisplayName);
+ } else {
+ // For widget matches: full path as header, widget text as content
+ QString pathText = result.tabName + QStringLiteral("/") + result.pageDisplayName;
+ item->setData(PathRole, pathText);
+ item->setData(WidgetTextRole, result.matchText);
+ }
+ item->setData(Qt::UserRole, i); // Keep existing index storage
+
+ m_searchResultsList->addItem(item);
+ }
+
+ // Select first actual item (not separator)
+ if (!m_searchResults.isEmpty()) {
+ m_searchResultsList->setCurrentRow(0);
+ }
+}
+
+void PreferencesSearchController::hideSearchResultsList()
+{
+ m_searchResultsList->setVisible(false);
+}
+
+void PreferencesSearchController::showSearchResultsList()
+{
+ // Configure popup size and position
+ configurePopupSize();
+
+ // Show the popup
+ m_searchResultsList->setVisible(true);
+ m_searchResultsList->raise();
+
+ // Use QTimer to ensure focus returns to search box after Qt finishes processing the popup show event
+ QTimer::singleShot(0, this, [this]() {
+ if (m_searchBox) {
+ m_searchBox->setFocus();
+ m_searchBox->activateWindow();
+ }
+ });
+}
+
+QString PreferencesSearchController::findGroupBoxForWidget(QWidget* widget)
+{
+ if (!widget) {
+ return QString();
+ }
+
+ // Walk up the parent hierarchy to find a QGroupBox
+ QWidget* parent = widget->parentWidget();
+ while (parent) {
+ QGroupBox* groupBox = qobject_cast(parent);
+ if (groupBox) {
+ return groupBox->title();
+ }
+ parent = parent->parentWidget();
+ }
+
+ return QString();
+}
+
+
+
+template
+void PreferencesSearchController::searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName,
+ const QString& pageName, const QString& pageDisplayName, const QString& tabName)
+{
+ const QList widgets = parentWidget->findChildren();
+
+ for (WidgetType* widget : widgets) {
+ QString widgetText;
+
+ // Get text based on widget type
+ if constexpr (std::is_same_v) {
+ widgetText = widget->text();
+ } else if constexpr (std::is_same_v) {
+ widgetText = widget->text();
+ } else if constexpr (std::is_same_v) {
+ widgetText = widget->text();
+ } else if constexpr (std::is_same_v) {
+ widgetText = widget->text();
+ }
+
+ // Use fuzzy matching instead of simple contains
+ int score = 0;
+ if (fuzzyMatch(searchText, widgetText, score)) {
+ SearchResult result
+ {
+ .groupName = groupName,
+ .pageName = pageName,
+ .widget = widget,
+ .matchText = widgetText,
+ .groupBoxName = findGroupBoxForWidget(widget),
+ .tabName = tabName,
+ .pageDisplayName = pageDisplayName,
+ .isPageLevelMatch = false,
+ .score = score
+ };
+ m_searchResults.append(result);
+ }
+ }
+}
+
+int PreferencesSearchController::calculatePopupHeight(int popupWidth)
+{
+ int totalHeight = 0;
+ int itemCount = m_searchResultsList->count();
+ int visibleItemCount = 0;
+ const int maxVisibleItems = 4;
+
+ for (int i = 0; i < itemCount && visibleItemCount < maxVisibleItems; ++i) {
+ QListWidgetItem* item = m_searchResultsList->item(i);
+ if (!item) continue;
+
+ // For separator items, use their widget height
+ if (m_searchResultsList->itemWidget(item)) {
+ totalHeight += m_searchResultsList->itemWidget(item)->sizeHint().height();
+ } else {
+ // For text items, use the delegate's size hint instead of calculating manually
+ QStyleOptionViewItem option;
+ option.rect = QRect(0, 0, popupWidth, 100); // Temporary rect for calculation
+ option.font = m_searchResultsList->font();
+
+ QSize delegateSize = m_searchResultsList->itemDelegate()->sizeHint(option, m_searchResultsList->model()->index(i, 0));
+ totalHeight += delegateSize.height();
+
+ visibleItemCount++; // Only count actual items, not separators
+ }
+ }
+
+ return qMax(50, totalHeight); // Minimum 50px height
+}
+
+void PreferencesSearchController::configurePopupSize()
+{
+ if (m_searchResults.isEmpty()) {
+ hideSearchResultsList();
+ return;
+ }
+
+ // Set a fixed width to prevent flashing when content changes
+ int popupWidth = 300; // Fixed width for consistent appearance
+ m_searchResultsList->setFixedWidth(popupWidth);
+
+ // Calculate and set the height
+ int finalHeight = calculatePopupHeight(popupWidth);
+ m_searchResultsList->setFixedHeight(finalHeight);
+
+ // Position the popup's upper-left corner at the upper-right corner of the search box
+ QPoint globalPos = m_searchBox->mapToGlobal(QPoint(m_searchBox->width(), 0));
+
+ // Check if popup would go off-screen to the right
+ QScreen* screen = QApplication::screenAt(globalPos);
+ if (!screen) {
+ screen = QApplication::primaryScreen();
+ }
+ QRect screenGeometry = screen->availableGeometry();
+
+ // If popup would extend beyond right edge of screen, position it below the search box instead
+ if (globalPos.x() + popupWidth > screenGeometry.right()) {
+ globalPos = m_searchBox->mapToGlobal(QPoint(0, m_searchBox->height()));
+ }
+
+ m_searchResultsList->move(globalPos);
+}
+
+// Fuzzy search implementation
+
+bool PreferencesSearchController::isExactMatch(const QString& searchText, const QString& targetText)
+{
+ return targetText.toLower().contains(searchText.toLower());
+}
+
+bool PreferencesSearchController::fuzzyMatch(const QString& searchText, const QString& targetText, int& score)
+{
+ if (searchText.isEmpty()) {
+ score = 0;
+ return true;
+ }
+
+ const QString lowerSearch = searchText.toLower();
+ const QString lowerTarget = targetText.toLower();
+
+ // First check for exact substring match (highest score)
+ if (lowerTarget.contains(lowerSearch)) {
+ // Score based on how early the match appears and how much of the string it covers
+ int matchIndex = lowerTarget.indexOf(lowerSearch);
+ int coverage = (lowerSearch.length() * 100) / lowerTarget.length(); // Percentage coverage
+ score = 1000 - matchIndex + coverage; // Higher score for earlier matches and better coverage
+ return true;
+ }
+
+ // For fuzzy matching, require minimum search length to avoid too many false positives
+ if (lowerSearch.length() < 3) {
+ score = 0;
+ return false;
+ }
+
+ // Fuzzy matching: check if all characters appear in order
+ int searchIndex = 0;
+ int targetIndex = 0;
+ int consecutiveMatches = 0;
+ int maxConsecutive = 0;
+ int totalMatches = 0;
+ int firstMatchIndex = -1;
+ int lastMatchIndex = -1;
+
+ while (searchIndex < lowerSearch.length() && targetIndex < lowerTarget.length()) {
+ if (lowerSearch[searchIndex] == lowerTarget[targetIndex]) {
+ if (firstMatchIndex == -1) {
+ firstMatchIndex = targetIndex;
+ }
+ lastMatchIndex = targetIndex;
+ searchIndex++;
+ totalMatches++;
+ consecutiveMatches++;
+ maxConsecutive = qMax(maxConsecutive, consecutiveMatches);
+ } else {
+ consecutiveMatches = 0;
+ }
+ targetIndex++;
+ }
+
+ // Check if all search characters were found
+ if (searchIndex == lowerSearch.length()) {
+ // Calculate match density - how spread out are the matches?
+ int matchSpan = lastMatchIndex - firstMatchIndex + 1;
+ int density = (lowerSearch.length() * 100) / matchSpan; // Characters per span
+
+ // Require minimum density - matches shouldn't be too spread out
+ if (density < 20) { // Less than 20% density is too sparse
+ score = 0;
+ return false;
+ }
+
+ // Require minimum coverage of search term
+ int coverage = (lowerSearch.length() * 100) / lowerTarget.length();
+ if (coverage < 15 && lowerTarget.length() > 20) { // For long strings, require better coverage
+ score = 0;
+ return false;
+ }
+
+ // Calculate score based on:
+ // - Match density (how compact the matches are)
+ // - Consecutive matches bonus
+ // - Coverage (how much of target string is the search term)
+ // - Position bonus (earlier matches are better)
+ int densityScore = qMin(density, 100); // Cap at 100
+ int consecutiveBonus = (maxConsecutive * 30) / lowerSearch.length();
+ int coverageScore = qMin(coverage * 2, 100); // Coverage is important
+ int positionBonus = qMax(0, 50 - firstMatchIndex); // Earlier is better
+
+ score = densityScore + consecutiveBonus + coverageScore + positionBonus;
+
+ // Minimum score threshold for fuzzy matches
+ if (score < 80) {
+ score = 0;
+ return false;
+ }
+
+ return true;
+ }
+
+ score = 0;
+ return false;
+}
+
+void PreferencesSearchController::ensureSearchBoxFocus()
+{
+ if (m_searchBox && !m_searchBox->hasFocus()) {
+ m_searchBox->setFocus();
+ }
+}
+
+QString PreferencesSearchController::getHighlightStyleForWidget(QWidget* widget)
+{
+ const QString baseStyle = QStringLiteral("background-color: #E3F2FD; color: #1565C0; border: 2px solid #2196F3; border-radius: 3px;");
+
+ if (qobject_cast(widget)) {
+ return QStringLiteral("QLabel { ") + baseStyle + QStringLiteral(" padding: 2px; }");
+ } else if (qobject_cast(widget)) {
+ return QStringLiteral("QCheckBox { ") + baseStyle + QStringLiteral(" padding: 2px; }");
+ } else if (qobject_cast(widget)) {
+ return QStringLiteral("QRadioButton { ") + baseStyle + QStringLiteral(" padding: 2px; }");
+ } else if (qobject_cast(widget)) {
+ return QStringLiteral("QGroupBox::title { ") + baseStyle + QStringLiteral(" padding: 2px; }");
+ } else if (qobject_cast(widget)) {
+ return QStringLiteral("QPushButton { ") + baseStyle + QStringLiteral(" }");
+ } else {
+ return QStringLiteral("QWidget { ") + baseStyle + QStringLiteral(" padding: 2px; }");
+ }
+}
+
+void PreferencesSearchController::applyHighlightToWidget(QWidget* widget)
+{
+ if (!widget) {
+ return;
+ }
+
+ m_originalStyles[widget] = widget->styleSheet();
+ widget->setStyleSheet(getHighlightStyleForWidget(widget));
+ m_highlightedWidgets.append(widget);
+}
+
+bool PreferencesSearchController::handleSearchBoxKeyPress(QKeyEvent* keyEvent)
+{
+ if (!m_searchResultsList->isVisible() || m_searchResults.isEmpty()) {
+ return false;
+ }
+
+ switch (keyEvent->key()) {
+ case Qt::Key_Down: {
+ // Move selection down in popup, skipping separators
+ int currentRow = m_searchResultsList->currentRow();
+ int totalItems = m_searchResultsList->count();
+ for (int i = 1; i < totalItems; ++i) {
+ int nextRow = (currentRow + i) % totalItems;
+ QListWidgetItem* item = m_searchResultsList->item(nextRow);
+ if (item && (item->flags() & Qt::ItemIsSelectable)) {
+ m_searchResultsList->setCurrentRow(nextRow);
+ break;
+ }
+ }
+ return true;
+ }
+ case Qt::Key_Up: {
+ // Move selection up in popup, skipping separators
+ int currentRow = m_searchResultsList->currentRow();
+ int totalItems = m_searchResultsList->count();
+ for (int i = 1; i < totalItems; ++i) {
+ int prevRow = (currentRow - i + totalItems) % totalItems;
+ QListWidgetItem* item = m_searchResultsList->item(prevRow);
+ if (item && (item->flags() & Qt::ItemIsSelectable)) {
+ m_searchResultsList->setCurrentRow(prevRow);
+ break;
+ }
+ }
+ return true;
+ }
+ case Qt::Key_Return:
+ case Qt::Key_Enter:
+ navigateToCurrentSearchResult(PopupAction::CloseAfter);
+ return true;
+ case Qt::Key_Escape:
+ hideSearchResultsList();
+ return true;
+ default:
+ return false;
+ }
+}
+
+bool PreferencesSearchController::handlePopupKeyPress(QKeyEvent* keyEvent)
+{
+ switch (keyEvent->key()) {
+ case Qt::Key_Return:
+ case Qt::Key_Enter:
+ navigateToCurrentSearchResult(PopupAction::CloseAfter);
+ return true;
+ case Qt::Key_Escape:
+ hideSearchResultsList();
+ ensureSearchBoxFocus();
+ return true;
+ default:
+ return false;
+ }
+}
+
+bool PreferencesSearchController::isClickOutsidePopup(QMouseEvent* mouseEvent)
+{
+#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
+ QPoint globalPos = mouseEvent->globalPos();
+#else
+ QPoint globalPos = mouseEvent->globalPosition().toPoint();
+#endif
+ QRect searchBoxRect = QRect(m_searchBox->mapToGlobal(QPoint(0, 0)), m_searchBox->size());
+ QRect popupRect = QRect(m_searchResultsList->mapToGlobal(QPoint(0, 0)), m_searchResultsList->size());
+
+ return !searchBoxRect.contains(globalPos) && !popupRect.contains(globalPos);
+}
+
+bool DlgPreferencesImp::eventFilter(QObject* obj, QEvent* event)
+{
+ // Handle search box key presses
+ if (obj == ui->searchBox && event->type() == QEvent::KeyPress) {
+ QKeyEvent* keyEvent = static_cast(event);
+ return m_searchController->handleSearchBoxKeyPress(keyEvent);
+ }
+
+ // Handle popup key presses
+ if (obj == m_searchController->getSearchResultsList() && event->type() == QEvent::KeyPress) {
+ QKeyEvent* keyEvent = static_cast(event);
+ return m_searchController->handlePopupKeyPress(keyEvent);
+ }
+
+ // Prevent popup from stealing focus
+ if (obj == m_searchController->getSearchResultsList() && event->type() == QEvent::FocusIn) {
+ m_searchController->ensureSearchBoxFocus();
+ return true;
+ }
+
+ // Handle search box focus loss
+ if (obj == ui->searchBox && event->type() == QEvent::FocusOut) {
+ QFocusEvent* focusEvent = static_cast(event);
+ if (focusEvent->reason() != Qt::PopupFocusReason &&
+ focusEvent->reason() != Qt::MouseFocusReason) {
+ // Only hide if focus is going somewhere else, not due to popup interaction
+ QTimer::singleShot(100, this, [this]() {
+ if (!ui->searchBox->hasFocus() && !m_searchController->isPopupUnderMouse()) {
+ m_searchController->hideSearchResultsList();
+ }
+ });
+ }
+ }
+
+ // Handle clicks outside popup
+ if (event->type() == QEvent::MouseButtonPress) {
+ QMouseEvent* mouseEvent = static_cast(event);
+ QWidget* widget = qobject_cast(obj);
+
+ // Check if click is outside search area
+ if (m_searchController->isPopupVisible() &&
+ obj != m_searchController->getSearchResultsList() &&
+ obj != ui->searchBox &&
+ widget && // Only check if obj is actually a QWidget
+ !m_searchController->isPopupAncestorOf(widget) &&
+ !ui->searchBox->isAncestorOf(widget)) {
+
+ if (m_searchController->isClickOutsidePopup(mouseEvent)) {
+ m_searchController->hideSearchResultsList();
+ m_searchController->clearHighlights();
+ }
+ }
+ }
+
+ return QDialog::eventFilter(obj, event);
+}
+
#include "moc_DlgPreferencesImp.cpp"
diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h
index 9d40d1d308..57c9d367ed 100644
--- a/src/Gui/Dialogs/DlgPreferencesImp.h
+++ b/src/Gui/Dialogs/DlgPreferencesImp.h
@@ -29,16 +29,134 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
class QAbstractButton;
class QListWidgetItem;
class QTabWidget;
+class QKeyEvent;
+class QMouseEvent;
namespace Gui::Dialog {
class PreferencePage;
class Ui_DlgPreferences;
+class DlgPreferencesImp;
+
+class GuiExport PreferencesSearchController : public QObject
+{
+ Q_OBJECT
+
+private:
+ enum class PopupAction {
+ KeepOpen, // don't close popup (used for keyboard navigation)
+ CloseAfter // close popup (used for mouse clicks and Enter/Return)
+ };
+
+public:
+ // Custom data roles for separating path and widget text
+ enum SearchDataRole {
+ PathRole = Qt::UserRole + 10, // Path to page (e.g., "Display/3D View")
+ WidgetTextRole = Qt::UserRole + 11 // Text from the widget (e.g., "Enable anti-aliasing")
+ };
+
+ // Search results structure
+ struct SearchResult {
+ QString groupName;
+ QString pageName;
+ QPointer widget;
+ QString matchText;
+ QString groupBoxName;
+ QString tabName; // The tab name (like "Display")
+ QString pageDisplayName; // The page display name (like "3D View")
+ bool isPageLevelMatch = false; // True if this is a page title match
+ int score = 0; // Fuzzy search score for sorting
+ };
+
+ explicit PreferencesSearchController(DlgPreferencesImp* parentDialog, QObject* parent = nullptr);
+ ~PreferencesSearchController() = default;
+
+ // Setup methods
+ void setPreferencesModel(QStandardItemModel* model);
+ void setGroupNameRole(int role);
+ void setPageNameRole(int role);
+
+ // UI access methods
+ QListWidget* getSearchResultsList() const;
+ bool isPopupVisible() const;
+ bool isPopupUnderMouse() const;
+ bool isPopupAncestorOf(QWidget* widget) const;
+
+ // Event handling
+ bool handleSearchBoxKeyPress(QKeyEvent* keyEvent);
+ bool handlePopupKeyPress(QKeyEvent* keyEvent);
+ bool isClickOutsidePopup(QMouseEvent* mouseEvent);
+
+ // Focus management
+ void ensureSearchBoxFocus();
+
+ // Search functionality
+ void performSearch(const QString& searchText);
+ void clearHighlights();
+ void hideSearchResultsList();
+ void showSearchResultsList();
+
+ // Navigation
+ void navigateToCurrentSearchResult(PopupAction action);
+
+Q_SIGNALS:
+ void navigationRequested(const QString& groupName, const QString& pageName);
+
+public Q_SLOTS:
+ void onSearchTextChanged(const QString& text);
+ void onSearchResultSelected();
+ void onSearchResultClicked();
+ void onSearchResultDoubleClicked();
+
+private:
+ // Search implementation
+ void collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName,
+ const QString& pageName, const QString& pageDisplayName, const QString& tabName);
+ void populateSearchResultsList();
+
+ template
+ void searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName,
+ const QString& pageName, const QString& pageDisplayName, const QString& tabName);
+
+ // UI helpers
+ void configurePopupSize();
+ int calculatePopupHeight(int popupWidth);
+ void applyHighlightToWidget(QWidget* widget);
+ QString getHighlightStyleForWidget(QWidget* widget);
+
+ // Search result navigation
+ void selectNextSearchResult();
+ void selectPreviousSearchResult();
+
+ // Utility methods
+ QString findGroupBoxForWidget(QWidget* widget);
+ bool fuzzyMatch(const QString& searchText, const QString& targetText, int& score);
+ bool isExactMatch(const QString& searchText, const QString& targetText);
+
+private:
+ DlgPreferencesImp* m_parentDialog;
+ QStandardItemModel* m_preferencesModel;
+ int m_groupNameRole;
+ int m_pageNameRole;
+
+ // UI components
+ QLineEdit* m_searchBox;
+ QListWidget* m_searchResultsList;
+
+ // Search state
+ QList m_searchResults;
+ QString m_lastSearchText;
+ QList m_highlightedWidgets;
+ QMap m_originalStyles;
+};
class PreferencesPageItem : public QStandardItem
{
@@ -155,6 +273,7 @@ public:
protected:
void changeEvent(QEvent *e) override;
void showEvent(QShowEvent*) override;
+ bool eventFilter(QObject* obj, QEvent* event) override;
protected Q_SLOTS:
void onButtonBoxClicked(QAbstractButton*);
@@ -163,6 +282,8 @@ protected Q_SLOTS:
void onGroupExpanded(const QModelIndex &index);
void onGroupCollapsed(const QModelIndex &index);
+
+ void onNavigationRequested(const QString& groupName, const QString& pageName);
private:
/** @name for internal use only */
@@ -190,6 +311,9 @@ private:
int minimumPageWidth() const;
int minimumDialogWidth(int) const;
void expandToMinimumDialogWidth();
+
+ // Navigation helper for search controller
+ void navigateToSearchResult(const QString& groupName, const QString& pageName);
//@}
private:
@@ -210,6 +334,9 @@ private:
bool invalidParameter;
bool canEmbedScrollArea;
bool restartRequired;
+
+ // Search controller
+ std::unique_ptr m_searchController;
/**< A name for our Qt::UserRole, used when storing user data in a list item */
static const int GroupNameRole;
@@ -219,6 +346,9 @@ private:
static constexpr char const* PageNameProperty = "PageName";
static DlgPreferencesImp* _activeDialog; /**< Defaults to the nullptr, points to the current instance if there is one */
+
+ // Friend class to allow search controller access to UI
+ friend class PreferencesSearchController;
};
} // namespace Gui