From 01729237b211c7a0c751b5777bc9ee0911b8a9a3 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sat, 14 Jun 2025 12:40:09 +0200 Subject: [PATCH 01/11] Core: Introduce searching in Preferences This PR introduces search box in preferences. Features: *supports left click on the result, taking user to the result *clicking anywhere cancels searching and closes popup box, same with ESC key *double click on the result closes the popup too (same behavior as enter) *supports enter (although if you are on the position you are already on it so enter just closes the popup basically) *escape closes it *you can navigate through the list with mouse *support fuzzy search so stuff like "OVP" is being matched to "On-View-Parameters" *there is hierarchical display (tab/page/setting) *some of the results are prioritized but fuzzy search prioritizing is the most important *highlights found item *goes to tab/page of found item *if the pop-up box won't fit next to the right side of the screen, it is added underneath the search box --- src/Gui/Dialogs/DlgPreferences.ui | 29 + src/Gui/Dialogs/DlgPreferencesImp.cpp | 846 +++++++++++++++++++++++++- src/Gui/Dialogs/DlgPreferencesImp.h | 62 ++ 3 files changed, 936 insertions(+), 1 deletion(-) 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..0f07d0251a 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,123 @@ using namespace Gui::Dialog; +// Simple delegate to render first line bold, second line normal +// used by search box +class MixedFontDelegate : public QStyledItemDelegate +{ +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 text = index.data(Qt::DisplayRole).toString(); + QStringList lines = text.split(QLatin1Char('\n')); + + if (lines.isEmpty()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + 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); + + // Set up fonts + QFont boldFont = option.font; + boldFont.setBold(true); + QFont normalFont = option.font; + normalFont.setPointSize(normalFont.pointSize() + 2); // make lower text 2 pixels bigger + + QFontMetrics boldFm(boldFont); + QFontMetrics normalFm(normalFont); + + int y = option.rect.top() + 4; // start 4px from top + int x = option.rect.left() + 12; // +12 horizontal padding + int availableWidth = option.rect.width() - 24; // account for left and right padding + + // draw first line in bold (Tab/Page) with wrapping + painter->setFont(boldFont); + QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.first()); + QRect boldRect(x, y, availableWidth, boldBoundingRect.height()); + painter->drawText(boldRect, Qt::TextWordWrap | Qt::AlignTop, lines.first()); + + // move y position after the bold text + y += boldBoundingRect.height(); + + // draw remaining lines in normal font with wrapping + if (lines.size() > 1) { + painter->setFont(normalFont); + + for (int i = 1; i < lines.size(); ++i) { + QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.at(i)); + QRect normalRect(x, y, availableWidth, normalBoundingRect.height()); + painter->drawText(normalRect, Qt::TextWordWrap | Qt::AlignTop, lines.at(i)); + y += normalBoundingRect.height(); + } + } + + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override + { + if (!index.isValid()) { + return QStyledItemDelegate::sizeHint(option, index); + } + + QString text = index.data(Qt::DisplayRole).toString(); + QStringList lines = text.split(QLatin1Char('\n')); + + if (lines.isEmpty()) { + return QStyledItemDelegate::sizeHint(option, index); + } + + QFont boldFont = option.font; + boldFont.setBold(true); + QFont normalFont = option.font; + normalFont.setPointSize(normalFont.pointSize() + 2); // Make lower text 2 pixels bigger to match paint method + + QFontMetrics boldFm(boldFont); + QFontMetrics normalFm(normalFont); + + int availableWidth = option.rect.width() - 24; // Account for left and right padding + if (availableWidth <= 0) { + availableWidth = 300 - 24; // Fallback to popup width minus padding + } + + int width = 0; + int height = 8; // Start with 8 vertical padding (4 top + 4 bottom) + + // Calculate height for first line (bold) with wrapping + QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.first()); + height += boldBoundingRect.height(); + width = qMax(width, boldBoundingRect.width() + 24); // +24 horizontal padding + + // Calculate height for remaining lines (normal font) with wrapping + for (int i = 1; i < lines.size(); ++i) { + QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.at(i)); + height += normalBoundingRect.height(); + width = qMax(width, normalBoundingRect.width() + 24); + } + + return QSize(width, height); + } +}; + bool isParentOf(const QModelIndex& parent, const QModelIndex& child) { for (auto it = child; it.isValid(); it = it.parent()) { @@ -126,13 +260,33 @@ DlgPreferencesImp* DlgPreferencesImp::_activeDialog = nullptr; */ DlgPreferencesImp::DlgPreferencesImp(QWidget* parent, Qt::WindowFlags fl) : QDialog(parent, fl), ui(new Ui_DlgPreferences), - invalidParameter(false), canEmbedScrollArea(true), restartRequired(false) + invalidParameter(false), canEmbedScrollArea(true), restartRequired(false), + searchResultsList(nullptr) { ui->setupUi(this); // remove unused help button setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + // Create the search results popup list + searchResultsList = new QListWidget(this); + searchResultsList->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + searchResultsList->setVisible(false); + searchResultsList->setMinimumWidth(300); + searchResultsList->setMaximumHeight(400); // Increased max height + searchResultsList->setFrameStyle(QFrame::Box | QFrame::Raised); + searchResultsList->setLineWidth(1); + searchResultsList->setFocusPolicy(Qt::NoFocus); // Don't steal focus from search box + searchResultsList->setAttribute(Qt::WA_ShowWithoutActivating); // Show without activating/stealing focus + searchResultsList->setWordWrap(true); // Enable word wrapping + searchResultsList->setTextElideMode(Qt::ElideNone); // Don't elide text, let it wrap instead + searchResultsList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Disable horizontal scrollbar + searchResultsList->setSpacing(0); // Remove spacing between items + searchResultsList->setContentsMargins(0, 0, 0, 0); // Remove margins + + // Set custom delegate for mixed font rendering (bold first line, normal second line) + searchResultsList->setItemDelegate(new MixedFontDelegate(searchResultsList)); + setupConnections(); ui->groupsTreeView->setModel(&_model); @@ -150,6 +304,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 +342,35 @@ void DlgPreferencesImp::setupConnections() &QStackedWidget::currentChanged, this, &DlgPreferencesImp::onStackWidgetChange); + connect(ui->searchBox, + &QLineEdit::textChanged, + this, + &DlgPreferencesImp::onSearchTextChanged); + + // 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); + + // Connect search results list + connect(searchResultsList, + &QListWidget::itemSelectionChanged, + this, + &DlgPreferencesImp::onSearchResultSelected); + connect(searchResultsList, + &QListWidget::itemDoubleClicked, + this, + &DlgPreferencesImp::onSearchResultDoubleClicked); + connect(searchResultsList, + &QListWidget::itemClicked, + this, + &DlgPreferencesImp::onSearchResultClicked); + + // Install event filter for keyboard navigation in search results + searchResultsList->installEventFilter(this); + + } void DlgPreferencesImp::setupPages() @@ -1007,4 +1193,662 @@ PreferencesPageItem* DlgPreferencesImp::getCurrentPage() const return pageWidget->property(PreferencesPageItem::PropertyName).value(); } +void DlgPreferencesImp::onSearchTextChanged(const QString& text) +{ + if (text.isEmpty()) { + clearSearchHighlights(); + searchResults.clear(); + lastSearchText.clear(); + hideSearchResultsList(); + return; + } + + // Only perform new search if text changed + if (text != lastSearchText) { + performSearch(text); + lastSearchText = text; + } +} + +void DlgPreferencesImp::performSearch(const QString& searchText) +{ + clearSearchHighlights(); + searchResults.clear(); + + if (searchText.length() < 2) { + hideSearchResultsList(); + return; + } + + // Search through all groups and pages to collect ALL results + auto root = _model.invisibleRootItem(); + for (int i = 0; i < root->rowCount(); i++) { + auto groupItem = static_cast(root->child(i)); + auto groupName = groupItem->data(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(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(searchResults.begin(), searchResults.end(), + [](const SearchResult& a, const SearchResult& b) { + return a.score > b.score; + }); + + // Update UI with search results + if (!searchResults.isEmpty()) { + populateSearchResultsList(); + showSearchResultsList(); + } else { + hideSearchResultsList(); + } +} + + + +void DlgPreferencesImp::clearSearchHighlights() +{ + // Restore original styles for all highlighted widgets + for (int i = 0; i < highlightedWidgets.size(); ++i) { + QWidget* widget = highlightedWidgets.at(i); + if (widget && originalStyles.contains(widget)) { + widget->setStyleSheet(originalStyles[widget]); + } + } + highlightedWidgets.clear(); + originalStyles.clear(); +} + + + +void DlgPreferencesImp::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; + result.groupName = groupName; + result.pageName = pageName; + result.widget = widget; // Use the page widget itself + result.matchText = pageDisplayName; // Use display name, not internal name + result.groupBoxName = QString(); // No groupbox for page-level match + result.tabName = tabName; + result.pageDisplayName = pageDisplayName; + result.isPageLevelMatch = true; // Mark as page-level match + result.score = pageScore + 2000; // Boost page-level matches + result.displayText = formatSearchResultText(result); + 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 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; + } + } +} + +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 handleSearchBoxKeyPress(keyEvent); + } + + // Handle popup key presses + if (obj == searchResultsList && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + return handlePopupKeyPress(keyEvent); + } + + // Prevent popup from stealing focus + if (obj == searchResultsList && event->type() == QEvent::FocusIn) { + 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() && !searchResultsList->underMouse()) { + 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 (searchResultsList->isVisible() && + obj != searchResultsList && + obj != ui->searchBox && + widget && // Only check if obj is actually a QWidget + !searchResultsList->isAncestorOf(widget) && + !ui->searchBox->isAncestorOf(widget)) { + + if (isClickOutsidePopup(mouseEvent)) { + hideSearchResultsList(); + } + } + } + + return QDialog::eventFilter(obj, event); +} + +void DlgPreferencesImp::onSearchResultSelected() +{ + // This method is called when a search result is selected (arrow keys or single click) + // Navigate immediately but keep popup open + if (searchResultsList && searchResultsList->currentItem()) { + navigateToCurrentSearchResult(false); // false = don't close popup + } + + ensureSearchBoxFocus(); +} + +void DlgPreferencesImp::onSearchResultClicked() +{ + // Handle single click - navigate immediately but keep popup open + if (searchResultsList && searchResultsList->currentItem()) { + navigateToCurrentSearchResult(false); // false = don't close popup + } + + ensureSearchBoxFocus(); +} + +void DlgPreferencesImp::onSearchResultDoubleClicked() +{ + // Handle double click - navigate and close popup + if (searchResultsList && searchResultsList->currentItem()) { + navigateToCurrentSearchResult(true); // true = close popup + } +} + +void DlgPreferencesImp::navigateToCurrentSearchResult(bool closePopup) +{ + QListWidgetItem* currentItem = 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 < searchResults.size()) { + const SearchResult& result = searchResults.at(resultIndex); + + // Navigate to the result + navigateToSearchResult(result.groupName, result.pageName); + + // Clear any existing highlights + clearSearchHighlights(); + + // 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 (closePopup) { + hideSearchResultsList(); + } + } +} + +void DlgPreferencesImp::populateSearchResultsList() +{ + searchResultsList->clear(); + + for (int i = 0; i < searchResults.size(); ++i) { + const SearchResult& result = searchResults.at(i); + QListWidgetItem* item = new QListWidgetItem(result.displayText); + item->setData(Qt::UserRole, i); // Store the index instead of pointer + searchResultsList->addItem(item); + } + + // Select first actual item (not separator) + if (!searchResults.isEmpty()) { + searchResultsList->setCurrentRow(0); + } +} + +void DlgPreferencesImp::hideSearchResultsList() +{ + searchResultsList->setVisible(false); +} + +void DlgPreferencesImp::showSearchResultsList() +{ + // Configure popup size and position + configurePopupSize(); + + // Show the popup + searchResultsList->setVisible(true); + 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 (ui->searchBox) { + ui->searchBox->setFocus(); + ui->searchBox->activateWindow(); + } + }); +} + +QString DlgPreferencesImp::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(); +} + +QString DlgPreferencesImp::formatSearchResultText(const SearchResult& result) +{ + // Format for MixedFontDelegate: First line will be bold, subsequent lines normal + QString text = result.tabName + QStringLiteral("/") + result.pageDisplayName; + + if (!result.isPageLevelMatch) { + // Add the actual finding on the second line + text += QStringLiteral("\n") + result.matchText; + } + + return text; +} + +void DlgPreferencesImp::createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, + const QString& pageName, const QString& pageDisplayName, const QString& tabName) +{ + SearchResult result; + result.groupName = groupName; + result.pageName = pageName; + result.widget = widget; + result.matchText = matchText; + result.groupBoxName = findGroupBoxForWidget(widget); + result.tabName = tabName; + result.pageDisplayName = pageDisplayName; + result.isPageLevelMatch = false; + result.score = 0; // Will be set by the caller + result.displayText = formatSearchResultText(result); + searchResults.append(result); +} + +template +void DlgPreferencesImp::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)) { + createSearchResult(widget, widgetText, groupName, pageName, pageDisplayName, tabName); + // Update the score of the last added result + if (!searchResults.isEmpty()) { + searchResults.last().score = score; + } + } + } +} + +int DlgPreferencesImp::calculatePopupHeight(int popupWidth) +{ + int totalHeight = 0; + int itemCount = searchResultsList->count(); + int visibleItemCount = 0; + const int maxVisibleItems = 4; + + for (int i = 0; i < itemCount && visibleItemCount < maxVisibleItems; ++i) { + QListWidgetItem* item = searchResultsList->item(i); + if (!item) continue; + + // For separator items, use their widget height + if (searchResultsList->itemWidget(item)) { + totalHeight += 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 = searchResultsList->font(); + + QSize delegateSize = searchResultsList->itemDelegate()->sizeHint(option, searchResultsList->model()->index(i, 0)); + totalHeight += delegateSize.height(); + + visibleItemCount++; // Only count actual items, not separators + } + } + + return qMax(50, totalHeight); // Minimum 50px height +} + +void DlgPreferencesImp::configurePopupSize() +{ + if (searchResults.isEmpty()) { + hideSearchResultsList(); + return; + } + + // Set a fixed width to prevent flashing when content changes + int popupWidth = 300; // Fixed width for consistent appearance + searchResultsList->setFixedWidth(popupWidth); + + // Calculate and set the height + int finalHeight = calculatePopupHeight(popupWidth); + searchResultsList->setFixedHeight(finalHeight); + + // Position the popup's upper-left corner at the upper-right corner of the search box + QPoint globalPos = ui->searchBox->mapToGlobal(QPoint(ui->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 = ui->searchBox->mapToGlobal(QPoint(0, ui->searchBox->height())); + } + + searchResultsList->move(globalPos); +} + +// Fuzzy search implementation + +bool DlgPreferencesImp::isExactMatch(const QString& searchText, const QString& targetText) +{ + return targetText.toLower().contains(searchText.toLower()); +} + +bool DlgPreferencesImp::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 DlgPreferencesImp::ensureSearchBoxFocus() +{ + if (ui->searchBox && !ui->searchBox->hasFocus()) { + ui->searchBox->setFocus(); + } +} + +QString DlgPreferencesImp::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 DlgPreferencesImp::applyHighlightToWidget(QWidget* widget) +{ + if (!widget) return; + + originalStyles[widget] = widget->styleSheet(); + widget->setStyleSheet(getHighlightStyleForWidget(widget)); + highlightedWidgets.append(widget); +} + + + +bool DlgPreferencesImp::handleSearchBoxKeyPress(QKeyEvent* keyEvent) +{ + if (!searchResultsList->isVisible() || searchResults.isEmpty()) { + return false; + } + + switch (keyEvent->key()) { + case Qt::Key_Down: { + // Move selection down in popup, skipping separators + int currentRow = searchResultsList->currentRow(); + int totalItems = searchResultsList->count(); + for (int i = 1; i < totalItems; ++i) { + int nextRow = (currentRow + i) % totalItems; + QListWidgetItem* item = searchResultsList->item(nextRow); + if (item && (item->flags() & Qt::ItemIsSelectable)) { + searchResultsList->setCurrentRow(nextRow); + break; + } + } + return true; + } + case Qt::Key_Up: { + // Move selection up in popup, skipping separators + int currentRow = searchResultsList->currentRow(); + int totalItems = searchResultsList->count(); + for (int i = 1; i < totalItems; ++i) { + int prevRow = (currentRow - i + totalItems) % totalItems; + QListWidgetItem* item = searchResultsList->item(prevRow); + if (item && (item->flags() & Qt::ItemIsSelectable)) { + searchResultsList->setCurrentRow(prevRow); + break; + } + } + return true; + } + case Qt::Key_Return: + case Qt::Key_Enter: + navigateToCurrentSearchResult(true); // true = close popup + return true; + case Qt::Key_Escape: + hideSearchResultsList(); + return true; + default: + return false; + } +} + +bool DlgPreferencesImp::handlePopupKeyPress(QKeyEvent* keyEvent) +{ + switch (keyEvent->key()) { + case Qt::Key_Return: + case Qt::Key_Enter: + navigateToCurrentSearchResult(true); // true = close popup + return true; + case Qt::Key_Escape: + hideSearchResultsList(); + ensureSearchBoxFocus(); + return true; + default: + return false; + } +} + +bool DlgPreferencesImp::isClickOutsidePopup(QMouseEvent* mouseEvent) +{ + QPoint globalPos = mouseEvent->globalPos(); + QRect searchBoxRect = QRect(ui->searchBox->mapToGlobal(QPoint(0, 0)), ui->searchBox->size()); + QRect popupRect = QRect(searchResultsList->mapToGlobal(QPoint(0, 0)), searchResultsList->size()); + + return !searchBoxRect.contains(globalPos) && !popupRect.contains(globalPos); +} + #include "moc_DlgPreferencesImp.cpp" diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h index 9d40d1d308..a24723ed09 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -29,6 +29,8 @@ #include #include #include +#include +#include #include #include @@ -134,6 +136,20 @@ class GuiExport DlgPreferencesImp : public QDialog static constexpr int minVerticalEmptySpace = 100; // px of vertical space to leave public: + // Search results navigation + 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") + QString displayText; + bool isPageLevelMatch = false; // True if this is a page title match + int score = 0; // Fuzzy search score for sorting + }; + static void addPage(const std::string& className, const std::string& group); static void removePage(const std::string& className, const std::string& group); static void setGroupData(const std::string& group, const std::string& icon, const QString& tip); @@ -155,6 +171,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 +180,11 @@ protected Q_SLOTS: void onGroupExpanded(const QModelIndex &index); void onGroupCollapsed(const QModelIndex &index); + + void onSearchTextChanged(const QString& text); + void onSearchResultSelected(); + void onSearchResultClicked(); + void onSearchResultDoubleClicked(); private: /** @name for internal use only */ @@ -190,6 +212,38 @@ private: int minimumPageWidth() const; int minimumDialogWidth(int) const; void expandToMinimumDialogWidth(); + + // Search functionality + void performSearch(const QString& searchText); + void clearSearchHighlights(); + void navigateToSearchResult(const QString& groupName, const QString& pageName); + void collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName); + + void populateSearchResultsList(); + void hideSearchResultsList(); + void showSearchResultsList(); + void navigateToCurrentSearchResult(bool closePopup); + QString findGroupBoxForWidget(QWidget* widget); + QString formatSearchResultText(const SearchResult& result); + + void createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, + const QString& pageName, const QString& pageDisplayName, const QString& tabName); + template + void searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, + const QString& pageName, const QString& pageDisplayName, const QString& tabName); + int calculatePopupHeight(int popupWidth); + void configurePopupSize(); + + // Fuzzy search helpers (for search box inside preferences)) + bool fuzzyMatch(const QString& searchText, const QString& targetText, int& score); + bool isExactMatch(const QString& searchText, const QString& targetText); + + void ensureSearchBoxFocus(); + void applyHighlightToWidget(QWidget* widget); + QString getHighlightStyleForWidget(QWidget* widget); + bool handleSearchBoxKeyPress(QKeyEvent* keyEvent); + bool handlePopupKeyPress(QKeyEvent* keyEvent); + bool isClickOutsidePopup(QMouseEvent* mouseEvent); //@} private: @@ -210,6 +264,14 @@ private: bool invalidParameter; bool canEmbedScrollArea; bool restartRequired; + + // Search state + QList highlightedWidgets; + QMap originalStyles; + + QList searchResults; + QString lastSearchText; + QListWidget* searchResultsList; /**< A name for our Qt::UserRole, used when storing user data in a list item */ static const int GroupNameRole; From 6a024ea4e16eb22d2cc9a99b6d712c50b0d5ad27 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sat, 21 Jun 2025 14:28:31 +0200 Subject: [PATCH 02/11] Core: Formatting changes for search in preferences --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 0f07d0251a..7b7a393044 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -369,8 +369,6 @@ void DlgPreferencesImp::setupConnections() // Install event filter for keyboard navigation in search results searchResultsList->installEventFilter(this); - - } void DlgPreferencesImp::setupPages() @@ -1257,8 +1255,6 @@ void DlgPreferencesImp::performSearch(const QString& searchText) } } - - void DlgPreferencesImp::clearSearchHighlights() { // Restore original styles for all highlighted widgets @@ -1272,8 +1268,6 @@ void DlgPreferencesImp::clearSearchHighlights() originalStyles.clear(); } - - void DlgPreferencesImp::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) { if (!widget) return; @@ -1501,7 +1495,9 @@ void DlgPreferencesImp::showSearchResultsList() QString DlgPreferencesImp::findGroupBoxForWidget(QWidget* widget) { - if (!widget) return QString(); + if (!widget) { + return QString(); + } // Walk up the parent hierarchy to find a QGroupBox QWidget* parent = widget->parentWidget(); @@ -1844,11 +1840,11 @@ bool DlgPreferencesImp::handlePopupKeyPress(QKeyEvent* keyEvent) bool DlgPreferencesImp::isClickOutsidePopup(QMouseEvent* mouseEvent) { - QPoint globalPos = mouseEvent->globalPos(); + QPointF globalPos = mouseEvent->globalPosition(); QRect searchBoxRect = QRect(ui->searchBox->mapToGlobal(QPoint(0, 0)), ui->searchBox->size()); QRect popupRect = QRect(searchResultsList->mapToGlobal(QPoint(0, 0)), searchResultsList->size()); - return !searchBoxRect.contains(globalPos) && !popupRect.contains(globalPos); + return !searchBoxRect.contains(globalPos.x(), globalPos.y()) && !popupRect.contains(globalPos.x(), globalPos.y()); } #include "moc_DlgPreferencesImp.cpp" From 4c1f2107dd3c4e4c7e8c6f72b83d8298928d7aaa Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sat, 21 Jun 2025 18:30:21 +0200 Subject: [PATCH 03/11] Core: Extract preferences search bar to it's own class --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 530 +++++++++++++++----------- src/Gui/Dialogs/DlgPreferencesImp.h | 169 +++++--- 2 files changed, 417 insertions(+), 282 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 7b7a393044..ae338a809a 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -260,36 +260,24 @@ DlgPreferencesImp* DlgPreferencesImp::_activeDialog = nullptr; */ DlgPreferencesImp::DlgPreferencesImp(QWidget* parent, Qt::WindowFlags fl) : QDialog(parent, fl), ui(new Ui_DlgPreferences), - invalidParameter(false), canEmbedScrollArea(true), restartRequired(false), - searchResultsList(nullptr) + invalidParameter(false), canEmbedScrollArea(true), restartRequired(false) { ui->setupUi(this); // remove unused help button setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); - // Create the search results popup list - searchResultsList = new QListWidget(this); - searchResultsList->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); - searchResultsList->setVisible(false); - searchResultsList->setMinimumWidth(300); - searchResultsList->setMaximumHeight(400); // Increased max height - searchResultsList->setFrameStyle(QFrame::Box | QFrame::Raised); - searchResultsList->setLineWidth(1); - searchResultsList->setFocusPolicy(Qt::NoFocus); // Don't steal focus from search box - searchResultsList->setAttribute(Qt::WA_ShowWithoutActivating); // Show without activating/stealing focus - searchResultsList->setWordWrap(true); // Enable word wrapping - searchResultsList->setTextElideMode(Qt::ElideNone); // Don't elide text, let it wrap instead - searchResultsList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // Disable horizontal scrollbar - searchResultsList->setSpacing(0); // Remove spacing between items - searchResultsList->setContentsMargins(0, 0, 0, 0); // Remove margins + // Initialize search controller + m_searchController = std::make_unique(this, this); - // Set custom delegate for mixed font rendering (bold first line, normal second line) - searchResultsList->setItemDelegate(new MixedFontDelegate(searchResultsList)); - setupConnections(); ui->groupsTreeView->setModel(&_model); + + // Configure search controller after UI setup + m_searchController->setPreferencesModel(&_model); + m_searchController->setGroupNameRole(GroupNameRole); + m_searchController->setPageNameRole(PageNameRole); setupPages(); @@ -342,33 +330,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::onSearchTextChanged); + &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); - - // Connect search results list - connect(searchResultsList, - &QListWidget::itemSelectionChanged, - this, - &DlgPreferencesImp::onSearchResultSelected); - connect(searchResultsList, - &QListWidget::itemDoubleClicked, - this, - &DlgPreferencesImp::onSearchResultDoubleClicked); - connect(searchResultsList, - &QListWidget::itemClicked, - this, - &DlgPreferencesImp::onSearchResultClicked); - - // Install event filter for keyboard navigation in search results - searchResultsList->installEventFilter(this); } void DlgPreferencesImp::setupPages() @@ -1087,6 +1065,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) { @@ -1191,27 +1208,117 @@ PreferencesPageItem* DlgPreferencesImp::getCurrentPage() const return pageWidget->property(PreferencesPageItem::PropertyName).value(); } -void DlgPreferencesImp::onSearchTextChanged(const QString& text) +// 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); + 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); +} + +PreferencesSearchController::~PreferencesSearchController() +{ + // Destructor - cleanup handled by Qt's object system +} + +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()) { - clearSearchHighlights(); - searchResults.clear(); - lastSearchText.clear(); + clearHighlights(); + m_searchResults.clear(); + m_lastSearchText.clear(); hideSearchResultsList(); return; } // Only perform new search if text changed - if (text != lastSearchText) { + if (text != m_lastSearchText) { performSearch(text); - lastSearchText = text; + m_lastSearchText = text; } } -void DlgPreferencesImp::performSearch(const QString& searchText) +void PreferencesSearchController::performSearch(const QString& searchText) { - clearSearchHighlights(); - searchResults.clear(); + clearHighlights(); + m_searchResults.clear(); if (searchText.length() < 2) { hideSearchResultsList(); @@ -1219,21 +1326,25 @@ void DlgPreferencesImp::performSearch(const QString& searchText) } // Search through all groups and pages to collect ALL results - auto root = _model.invisibleRootItem(); + auto root = m_preferencesModel->invisibleRootItem(); for (int i = 0; i < root->rowCount(); i++) { auto groupItem = static_cast(root->child(i)); - auto groupName = groupItem->data(GroupNameRole).toString(); + auto groupName = groupItem->data(m_groupNameRole).toString(); auto groupStack = qobject_cast(groupItem->getWidget()); - if (!groupStack) continue; + 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(PageNameRole).toString(); + auto pageName = pageItem->data(m_pageNameRole).toString(); auto pageWidget = qobject_cast(pageItem->getWidget()); - if (!pageWidget) continue; + if (!pageWidget) { + continue; + } // Collect all matching widgets in this page collectSearchResults(pageWidget, searchText, groupName, pageName, pageItem->text(), groupItem->text()); @@ -1241,13 +1352,13 @@ void DlgPreferencesImp::performSearch(const QString& searchText) } // Sort results by score (highest first) - std::sort(searchResults.begin(), searchResults.end(), + 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 (!searchResults.isEmpty()) { + if (!m_searchResults.isEmpty()) { populateSearchResultsList(); showSearchResultsList(); } else { @@ -1255,20 +1366,20 @@ void DlgPreferencesImp::performSearch(const QString& searchText) } } -void DlgPreferencesImp::clearSearchHighlights() +void PreferencesSearchController::clearHighlights() { // Restore original styles for all highlighted widgets - for (int i = 0; i < highlightedWidgets.size(); ++i) { - QWidget* widget = highlightedWidgets.at(i); - if (widget && originalStyles.contains(widget)) { - widget->setStyleSheet(originalStyles[widget]); + 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]); } } - highlightedWidgets.clear(); - originalStyles.clear(); + m_highlightedWidgets.clear(); + m_originalStyles.clear(); } -void DlgPreferencesImp::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) +void PreferencesSearchController::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) { if (!widget) return; @@ -1288,7 +1399,7 @@ void DlgPreferencesImp::collectSearchResults(QWidget* widget, const QString& sea result.isPageLevelMatch = true; // Mark as page-level match result.score = pageScore + 2000; // Boost page-level matches result.displayText = formatSearchResultText(result); - searchResults.append(result); + m_searchResults.append(result); // Continue searching for individual items even if page matches } @@ -1299,128 +1410,38 @@ void DlgPreferencesImp::collectSearchResults(QWidget* widget, const QString& sea searchWidgetType(widget, searchText, groupName, pageName, pageDisplayName, tabName); } -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; - } - } -} - -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 handleSearchBoxKeyPress(keyEvent); - } - - // Handle popup key presses - if (obj == searchResultsList && event->type() == QEvent::KeyPress) { - QKeyEvent* keyEvent = static_cast(event); - return handlePopupKeyPress(keyEvent); - } - - // Prevent popup from stealing focus - if (obj == searchResultsList && event->type() == QEvent::FocusIn) { - 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() && !searchResultsList->underMouse()) { - 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 (searchResultsList->isVisible() && - obj != searchResultsList && - obj != ui->searchBox && - widget && // Only check if obj is actually a QWidget - !searchResultsList->isAncestorOf(widget) && - !ui->searchBox->isAncestorOf(widget)) { - - if (isClickOutsidePopup(mouseEvent)) { - hideSearchResultsList(); - } - } - } - - return QDialog::eventFilter(obj, event); -} - -void DlgPreferencesImp::onSearchResultSelected() +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 (searchResultsList && searchResultsList->currentItem()) { + if (m_searchResultsList && m_searchResultsList->currentItem()) { navigateToCurrentSearchResult(false); // false = don't close popup } ensureSearchBoxFocus(); } -void DlgPreferencesImp::onSearchResultClicked() +void PreferencesSearchController::onSearchResultClicked() { // Handle single click - navigate immediately but keep popup open - if (searchResultsList && searchResultsList->currentItem()) { + if (m_searchResultsList && m_searchResultsList->currentItem()) { navigateToCurrentSearchResult(false); // false = don't close popup } ensureSearchBoxFocus(); } -void DlgPreferencesImp::onSearchResultDoubleClicked() +void PreferencesSearchController::onSearchResultDoubleClicked() { // Handle double click - navigate and close popup - if (searchResultsList && searchResultsList->currentItem()) { + if (m_searchResultsList && m_searchResultsList->currentItem()) { navigateToCurrentSearchResult(true); // true = close popup } } -void DlgPreferencesImp::navigateToCurrentSearchResult(bool closePopup) +void PreferencesSearchController::navigateToCurrentSearchResult(bool closePopup) { - QListWidgetItem* currentItem = searchResultsList->currentItem(); + QListWidgetItem* currentItem = m_searchResultsList->currentItem(); // Skip if it's a separator (non-selectable item) or no item selected if (!currentItem || !(currentItem->flags() & Qt::ItemIsSelectable)) { @@ -1431,14 +1452,14 @@ void DlgPreferencesImp::navigateToCurrentSearchResult(bool closePopup) bool ok; int resultIndex = currentItem->data(Qt::UserRole).toInt(&ok); - if (ok && resultIndex >= 0 && resultIndex < searchResults.size()) { - const SearchResult& result = searchResults.at(resultIndex); + if (ok && resultIndex >= 0 && resultIndex < m_searchResults.size()) { + const SearchResult& result = m_searchResults.at(resultIndex); - // Navigate to the result - navigateToSearchResult(result.groupName, result.pageName); + // Emit signal to request navigation + Q_EMIT navigationRequested(result.groupName, result.pageName); // Clear any existing highlights - clearSearchHighlights(); + clearHighlights(); // Only highlight specific widgets for non-page-level matches if (!result.isPageLevelMatch && !result.widget.isNull()) { @@ -1453,47 +1474,47 @@ void DlgPreferencesImp::navigateToCurrentSearchResult(bool closePopup) } } -void DlgPreferencesImp::populateSearchResultsList() +void PreferencesSearchController::populateSearchResultsList() { - searchResultsList->clear(); + m_searchResultsList->clear(); - for (int i = 0; i < searchResults.size(); ++i) { - const SearchResult& result = searchResults.at(i); + for (int i = 0; i < m_searchResults.size(); ++i) { + const SearchResult& result = m_searchResults.at(i); QListWidgetItem* item = new QListWidgetItem(result.displayText); item->setData(Qt::UserRole, i); // Store the index instead of pointer - searchResultsList->addItem(item); + m_searchResultsList->addItem(item); } // Select first actual item (not separator) - if (!searchResults.isEmpty()) { - searchResultsList->setCurrentRow(0); + if (!m_searchResults.isEmpty()) { + m_searchResultsList->setCurrentRow(0); } } -void DlgPreferencesImp::hideSearchResultsList() +void PreferencesSearchController::hideSearchResultsList() { - searchResultsList->setVisible(false); + m_searchResultsList->setVisible(false); } -void DlgPreferencesImp::showSearchResultsList() +void PreferencesSearchController::showSearchResultsList() { // Configure popup size and position configurePopupSize(); // Show the popup - searchResultsList->setVisible(true); - searchResultsList->raise(); + 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 (ui->searchBox) { - ui->searchBox->setFocus(); - ui->searchBox->activateWindow(); + if (m_searchBox) { + m_searchBox->setFocus(); + m_searchBox->activateWindow(); } }); } -QString DlgPreferencesImp::findGroupBoxForWidget(QWidget* widget) +QString PreferencesSearchController::findGroupBoxForWidget(QWidget* widget) { if (!widget) { return QString(); @@ -1512,7 +1533,7 @@ QString DlgPreferencesImp::findGroupBoxForWidget(QWidget* widget) return QString(); } -QString DlgPreferencesImp::formatSearchResultText(const SearchResult& result) +QString PreferencesSearchController::formatSearchResultText(const SearchResult& result) { // Format for MixedFontDelegate: First line will be bold, subsequent lines normal QString text = result.tabName + QStringLiteral("/") + result.pageDisplayName; @@ -1525,7 +1546,7 @@ QString DlgPreferencesImp::formatSearchResultText(const SearchResult& result) return text; } -void DlgPreferencesImp::createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, +void PreferencesSearchController::createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) { SearchResult result; @@ -1539,11 +1560,11 @@ void DlgPreferencesImp::createSearchResult(QWidget* widget, const QString& match result.isPageLevelMatch = false; result.score = 0; // Will be set by the caller result.displayText = formatSearchResultText(result); - searchResults.append(result); + m_searchResults.append(result); } template -void DlgPreferencesImp::searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, +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(); @@ -1567,34 +1588,34 @@ void DlgPreferencesImp::searchWidgetType(QWidget* parentWidget, const QString& s if (fuzzyMatch(searchText, widgetText, score)) { createSearchResult(widget, widgetText, groupName, pageName, pageDisplayName, tabName); // Update the score of the last added result - if (!searchResults.isEmpty()) { - searchResults.last().score = score; + if (!m_searchResults.isEmpty()) { + m_searchResults.last().score = score; } } } } -int DlgPreferencesImp::calculatePopupHeight(int popupWidth) +int PreferencesSearchController::calculatePopupHeight(int popupWidth) { int totalHeight = 0; - int itemCount = searchResultsList->count(); + int itemCount = m_searchResultsList->count(); int visibleItemCount = 0; const int maxVisibleItems = 4; for (int i = 0; i < itemCount && visibleItemCount < maxVisibleItems; ++i) { - QListWidgetItem* item = searchResultsList->item(i); + QListWidgetItem* item = m_searchResultsList->item(i); if (!item) continue; // For separator items, use their widget height - if (searchResultsList->itemWidget(item)) { - totalHeight += searchResultsList->itemWidget(item)->sizeHint().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 = searchResultsList->font(); + option.font = m_searchResultsList->font(); - QSize delegateSize = searchResultsList->itemDelegate()->sizeHint(option, searchResultsList->model()->index(i, 0)); + QSize delegateSize = m_searchResultsList->itemDelegate()->sizeHint(option, m_searchResultsList->model()->index(i, 0)); totalHeight += delegateSize.height(); visibleItemCount++; // Only count actual items, not separators @@ -1604,23 +1625,23 @@ int DlgPreferencesImp::calculatePopupHeight(int popupWidth) return qMax(50, totalHeight); // Minimum 50px height } -void DlgPreferencesImp::configurePopupSize() +void PreferencesSearchController::configurePopupSize() { - if (searchResults.isEmpty()) { + if (m_searchResults.isEmpty()) { hideSearchResultsList(); return; } // Set a fixed width to prevent flashing when content changes int popupWidth = 300; // Fixed width for consistent appearance - searchResultsList->setFixedWidth(popupWidth); + m_searchResultsList->setFixedWidth(popupWidth); // Calculate and set the height int finalHeight = calculatePopupHeight(popupWidth); - searchResultsList->setFixedHeight(finalHeight); + m_searchResultsList->setFixedHeight(finalHeight); // Position the popup's upper-left corner at the upper-right corner of the search box - QPoint globalPos = ui->searchBox->mapToGlobal(QPoint(ui->searchBox->width(), 0)); + 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); @@ -1631,20 +1652,20 @@ void DlgPreferencesImp::configurePopupSize() // If popup would extend beyond right edge of screen, position it below the search box instead if (globalPos.x() + popupWidth > screenGeometry.right()) { - globalPos = ui->searchBox->mapToGlobal(QPoint(0, ui->searchBox->height())); + globalPos = m_searchBox->mapToGlobal(QPoint(0, m_searchBox->height())); } - searchResultsList->move(globalPos); + m_searchResultsList->move(globalPos); } // Fuzzy search implementation -bool DlgPreferencesImp::isExactMatch(const QString& searchText, const QString& targetText) +bool PreferencesSearchController::isExactMatch(const QString& searchText, const QString& targetText) { return targetText.toLower().contains(searchText.toLower()); } -bool DlgPreferencesImp::fuzzyMatch(const QString& searchText, const QString& targetText, int& score) +bool PreferencesSearchController::fuzzyMatch(const QString& searchText, const QString& targetText, int& score) { if (searchText.isEmpty()) { score = 0; @@ -1738,14 +1759,14 @@ bool DlgPreferencesImp::fuzzyMatch(const QString& searchText, const QString& tar return false; } -void DlgPreferencesImp::ensureSearchBoxFocus() +void PreferencesSearchController::ensureSearchBoxFocus() { - if (ui->searchBox && !ui->searchBox->hasFocus()) { - ui->searchBox->setFocus(); + if (m_searchBox && !m_searchBox->hasFocus()) { + m_searchBox->setFocus(); } } -QString DlgPreferencesImp::getHighlightStyleForWidget(QWidget* widget) +QString PreferencesSearchController::getHighlightStyleForWidget(QWidget* widget) { const QString baseStyle = QStringLiteral("background-color: #E3F2FD; color: #1565C0; border: 2px solid #2196F3; border-radius: 3px;"); @@ -1764,33 +1785,31 @@ QString DlgPreferencesImp::getHighlightStyleForWidget(QWidget* widget) } } -void DlgPreferencesImp::applyHighlightToWidget(QWidget* widget) +void PreferencesSearchController::applyHighlightToWidget(QWidget* widget) { if (!widget) return; - originalStyles[widget] = widget->styleSheet(); + m_originalStyles[widget] = widget->styleSheet(); widget->setStyleSheet(getHighlightStyleForWidget(widget)); - highlightedWidgets.append(widget); + m_highlightedWidgets.append(widget); } - - -bool DlgPreferencesImp::handleSearchBoxKeyPress(QKeyEvent* keyEvent) +bool PreferencesSearchController::handleSearchBoxKeyPress(QKeyEvent* keyEvent) { - if (!searchResultsList->isVisible() || searchResults.isEmpty()) { + 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 = searchResultsList->currentRow(); - int totalItems = searchResultsList->count(); + int currentRow = m_searchResultsList->currentRow(); + int totalItems = m_searchResultsList->count(); for (int i = 1; i < totalItems; ++i) { int nextRow = (currentRow + i) % totalItems; - QListWidgetItem* item = searchResultsList->item(nextRow); + QListWidgetItem* item = m_searchResultsList->item(nextRow); if (item && (item->flags() & Qt::ItemIsSelectable)) { - searchResultsList->setCurrentRow(nextRow); + m_searchResultsList->setCurrentRow(nextRow); break; } } @@ -1798,13 +1817,13 @@ bool DlgPreferencesImp::handleSearchBoxKeyPress(QKeyEvent* keyEvent) } case Qt::Key_Up: { // Move selection up in popup, skipping separators - int currentRow = searchResultsList->currentRow(); - int totalItems = searchResultsList->count(); + 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 = searchResultsList->item(prevRow); + QListWidgetItem* item = m_searchResultsList->item(prevRow); if (item && (item->flags() & Qt::ItemIsSelectable)) { - searchResultsList->setCurrentRow(prevRow); + m_searchResultsList->setCurrentRow(prevRow); break; } } @@ -1822,7 +1841,7 @@ bool DlgPreferencesImp::handleSearchBoxKeyPress(QKeyEvent* keyEvent) } } -bool DlgPreferencesImp::handlePopupKeyPress(QKeyEvent* keyEvent) +bool PreferencesSearchController::handlePopupKeyPress(QKeyEvent* keyEvent) { switch (keyEvent->key()) { case Qt::Key_Return: @@ -1838,13 +1857,70 @@ bool DlgPreferencesImp::handlePopupKeyPress(QKeyEvent* keyEvent) } } -bool DlgPreferencesImp::isClickOutsidePopup(QMouseEvent* mouseEvent) +bool PreferencesSearchController::isClickOutsidePopup(QMouseEvent* mouseEvent) { QPointF globalPos = mouseEvent->globalPosition(); - QRect searchBoxRect = QRect(ui->searchBox->mapToGlobal(QPoint(0, 0)), ui->searchBox->size()); - QRect popupRect = QRect(searchResultsList->mapToGlobal(QPoint(0, 0)), searchResultsList->size()); + 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.x(), globalPos.y()) && !popupRect.contains(globalPos.x(), globalPos.y()); } +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 a24723ed09..d67f577fb5 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -31,16 +31,123 @@ #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 +public: + // 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") + QString displayText; + 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(); + + // 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(bool closePopup); + +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(); + void createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, + const QString& pageName, const QString& pageDisplayName, const QString& tabName); + + 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); + QString formatSearchResultText(const SearchResult& result); + 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 { @@ -136,20 +243,6 @@ class GuiExport DlgPreferencesImp : public QDialog static constexpr int minVerticalEmptySpace = 100; // px of vertical space to leave public: - // Search results navigation - 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") - QString displayText; - bool isPageLevelMatch = false; // True if this is a page title match - int score = 0; // Fuzzy search score for sorting - }; - static void addPage(const std::string& className, const std::string& group); static void removePage(const std::string& className, const std::string& group); static void setGroupData(const std::string& group, const std::string& icon, const QString& tip); @@ -181,10 +274,7 @@ protected Q_SLOTS: void onGroupExpanded(const QModelIndex &index); void onGroupCollapsed(const QModelIndex &index); - void onSearchTextChanged(const QString& text); - void onSearchResultSelected(); - void onSearchResultClicked(); - void onSearchResultDoubleClicked(); + void onNavigationRequested(const QString& groupName, const QString& pageName); private: /** @name for internal use only */ @@ -213,37 +303,8 @@ private: int minimumDialogWidth(int) const; void expandToMinimumDialogWidth(); - // Search functionality - void performSearch(const QString& searchText); - void clearSearchHighlights(); + // Navigation helper for search controller void navigateToSearchResult(const QString& groupName, const QString& pageName); - void collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName); - - void populateSearchResultsList(); - void hideSearchResultsList(); - void showSearchResultsList(); - void navigateToCurrentSearchResult(bool closePopup); - QString findGroupBoxForWidget(QWidget* widget); - QString formatSearchResultText(const SearchResult& result); - - void createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, - const QString& pageName, const QString& pageDisplayName, const QString& tabName); - template - void searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, - const QString& pageName, const QString& pageDisplayName, const QString& tabName); - int calculatePopupHeight(int popupWidth); - void configurePopupSize(); - - // Fuzzy search helpers (for search box inside preferences)) - bool fuzzyMatch(const QString& searchText, const QString& targetText, int& score); - bool isExactMatch(const QString& searchText, const QString& targetText); - - void ensureSearchBoxFocus(); - void applyHighlightToWidget(QWidget* widget); - QString getHighlightStyleForWidget(QWidget* widget); - bool handleSearchBoxKeyPress(QKeyEvent* keyEvent); - bool handlePopupKeyPress(QKeyEvent* keyEvent); - bool isClickOutsidePopup(QMouseEvent* mouseEvent); //@} private: @@ -265,13 +326,8 @@ private: bool canEmbedScrollArea; bool restartRequired; - // Search state - QList highlightedWidgets; - QMap originalStyles; - - QList searchResults; - QString lastSearchText; - QListWidget* searchResultsList; + // 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; @@ -281,6 +337,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 From e3dd8ec134b1a7910181cfe19d97d7a23658708a Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 12:12:33 +0200 Subject: [PATCH 04/11] Core: Use an enum for search bar popup in preferences --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 23 ++++++++++------------- src/Gui/Dialogs/DlgPreferencesImp.h | 11 +++++++++-- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index ae338a809a..ede3e863b9 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -1258,11 +1258,6 @@ PreferencesSearchController::PreferencesSearchController(DlgPreferencesImp* pare m_searchResultsList->installEventFilter(m_parentDialog); } -PreferencesSearchController::~PreferencesSearchController() -{ - // Destructor - cleanup handled by Qt's object system -} - void PreferencesSearchController::setPreferencesModel(QStandardItemModel* model) { m_preferencesModel = model; @@ -1381,7 +1376,9 @@ void PreferencesSearchController::clearHighlights() void PreferencesSearchController::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) { - if (!widget) return; + if (!widget) { + return; + } const QString lowerSearchText = searchText.toLower(); @@ -1415,7 +1412,7 @@ 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(false); // false = don't close popup + navigateToCurrentSearchResult(PopupAction::KeepOpen); } ensureSearchBoxFocus(); @@ -1425,7 +1422,7 @@ void PreferencesSearchController::onSearchResultClicked() { // Handle single click - navigate immediately but keep popup open if (m_searchResultsList && m_searchResultsList->currentItem()) { - navigateToCurrentSearchResult(false); // false = don't close popup + navigateToCurrentSearchResult(PopupAction::KeepOpen); } ensureSearchBoxFocus(); @@ -1435,11 +1432,11 @@ void PreferencesSearchController::onSearchResultDoubleClicked() { // Handle double click - navigate and close popup if (m_searchResultsList && m_searchResultsList->currentItem()) { - navigateToCurrentSearchResult(true); // true = close popup + navigateToCurrentSearchResult(PopupAction::CloseAfter); } } -void PreferencesSearchController::navigateToCurrentSearchResult(bool closePopup) +void PreferencesSearchController::navigateToCurrentSearchResult(PopupAction action) { QListWidgetItem* currentItem = m_searchResultsList->currentItem(); @@ -1468,7 +1465,7 @@ void PreferencesSearchController::navigateToCurrentSearchResult(bool closePopup) // For page-level matches, we just navigate without highlighting anything // Close popup only if requested (double-click or Enter) - if (closePopup) { + if (action == PopupAction::CloseAfter) { hideSearchResultsList(); } } @@ -1831,7 +1828,7 @@ bool PreferencesSearchController::handleSearchBoxKeyPress(QKeyEvent* keyEvent) } case Qt::Key_Return: case Qt::Key_Enter: - navigateToCurrentSearchResult(true); // true = close popup + navigateToCurrentSearchResult(PopupAction::CloseAfter); return true; case Qt::Key_Escape: hideSearchResultsList(); @@ -1846,7 +1843,7 @@ bool PreferencesSearchController::handlePopupKeyPress(QKeyEvent* keyEvent) switch (keyEvent->key()) { case Qt::Key_Return: case Qt::Key_Enter: - navigateToCurrentSearchResult(true); // true = close popup + navigateToCurrentSearchResult(PopupAction::CloseAfter); return true; case Qt::Key_Escape: hideSearchResultsList(); diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h index d67f577fb5..d724879e23 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -49,6 +49,13 @@ 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: // Search results structure struct SearchResult { @@ -65,7 +72,7 @@ public: }; explicit PreferencesSearchController(DlgPreferencesImp* parentDialog, QObject* parent = nullptr); - ~PreferencesSearchController(); + ~PreferencesSearchController() = default; // Setup methods void setPreferencesModel(QStandardItemModel* model); @@ -93,7 +100,7 @@ public: void showSearchResultsList(); // Navigation - void navigateToCurrentSearchResult(bool closePopup); + void navigateToCurrentSearchResult(PopupAction action); Q_SIGNALS: void navigationRequested(const QString& groupName, const QString& pageName); From 53bd5a2ab7a4550e25393d4e55bd678051f89914 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 13:00:52 +0200 Subject: [PATCH 05/11] Core: Use designated init for SearchResult in preferences --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 60 ++++++++++++--------------- src/Gui/Dialogs/DlgPreferencesImp.h | 2 - 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index ede3e863b9..0b140899d0 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -1385,17 +1385,19 @@ void PreferencesSearchController::collectSearchResults(QWidget* widget, const QS // First, check if the page display name itself matches (highest priority) int pageScore = 0; if (fuzzyMatch(searchText, pageDisplayName, pageScore)) { - SearchResult result; - result.groupName = groupName; - result.pageName = pageName; - result.widget = widget; // Use the page widget itself - result.matchText = pageDisplayName; // Use display name, not internal name - result.groupBoxName = QString(); // No groupbox for page-level match - result.tabName = tabName; - result.pageDisplayName = pageDisplayName; - result.isPageLevelMatch = true; // Mark as page-level match - result.score = pageScore + 2000; // Boost page-level matches - result.displayText = formatSearchResultText(result); + 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, + .displayText = formatSearchResultText(result), + .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 } @@ -1543,23 +1545,6 @@ QString PreferencesSearchController::formatSearchResultText(const SearchResult& return text; } -void PreferencesSearchController::createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, - const QString& pageName, const QString& pageDisplayName, const QString& tabName) -{ - SearchResult result; - result.groupName = groupName; - result.pageName = pageName; - result.widget = widget; - result.matchText = matchText; - result.groupBoxName = findGroupBoxForWidget(widget); - result.tabName = tabName; - result.pageDisplayName = pageDisplayName; - result.isPageLevelMatch = false; - result.score = 0; // Will be set by the caller - result.displayText = formatSearchResultText(result); - m_searchResults.append(result); -} - template void PreferencesSearchController::searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) @@ -1583,11 +1568,20 @@ void PreferencesSearchController::searchWidgetType(QWidget* parentWidget, const // Use fuzzy matching instead of simple contains int score = 0; if (fuzzyMatch(searchText, widgetText, score)) { - createSearchResult(widget, widgetText, groupName, pageName, pageDisplayName, tabName); - // Update the score of the last added result - if (!m_searchResults.isEmpty()) { - m_searchResults.last().score = score; - } + SearchResult result + { + .groupName = groupName, + .pageName = pageName, + .widget = widget, + .matchText = widgetText, + .groupBoxName = findGroupBoxForWidget(widget), + .tabName = tabName, + .pageDisplayName = pageDisplayName, + .displayText = formatSearchResultText(result), + .isPageLevelMatch = false, + .score = score + }; + m_searchResults.append(result); } } } diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h index d724879e23..56fcd44fa0 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -116,8 +116,6 @@ private: void collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName); void populateSearchResultsList(); - void createSearchResult(QWidget* widget, const QString& matchText, const QString& groupName, - const QString& pageName, const QString& pageDisplayName, const QString& tabName); template void searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, From d06651e92c4e069d258b6a4d4e5c5bbcffe4b04b Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 13:50:41 +0200 Subject: [PATCH 06/11] Core: Use separate roles for found item in font delegate for search --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 82 +++++++++++++++++++-------- src/Gui/Dialogs/DlgPreferencesImp.h | 6 ++ 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 0b140899d0..ee620ad333 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -87,10 +87,22 @@ public: return; } - QString text = index.data(Qt::DisplayRole).toString(); - QStringList lines = text.split(QLatin1Char('\n')); + // Use separate roles instead of parsing mixed string + QString pathText = index.data(PreferencesSearchController::PathRole).toString(); + QString widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); + + if (pathText.isEmpty()) { + QString text = index.data(Qt::DisplayRole).toString(); + QStringList lines = text.split(QLatin1Char('\n')); + if (!lines.isEmpty()) { + pathText = lines.first(); + if (lines.size() > 1) { + widgetText = lines.at(1); + } + } + } - if (lines.isEmpty()) { + if (pathText.isEmpty()) { QStyledItemDelegate::paint(painter, option, index); return; } @@ -121,25 +133,21 @@ public: int x = option.rect.left() + 12; // +12 horizontal padding int availableWidth = option.rect.width() - 24; // account for left and right padding - // draw first line in bold (Tab/Page) with wrapping + // draw path in bold (Tab/Page) with wrapping painter->setFont(boldFont); - QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.first()); + QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, pathText); QRect boldRect(x, y, availableWidth, boldBoundingRect.height()); - painter->drawText(boldRect, Qt::TextWordWrap | Qt::AlignTop, lines.first()); + painter->drawText(boldRect, Qt::TextWordWrap | Qt::AlignTop, pathText); // move y position after the bold text y += boldBoundingRect.height(); - // draw remaining lines in normal font with wrapping - if (lines.size() > 1) { + // draw widget text in normal font (if present) + if (!widgetText.isEmpty()) { painter->setFont(normalFont); - - for (int i = 1; i < lines.size(); ++i) { - QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.at(i)); - QRect normalRect(x, y, availableWidth, normalBoundingRect.height()); - painter->drawText(normalRect, Qt::TextWordWrap | Qt::AlignTop, lines.at(i)); - y += normalBoundingRect.height(); - } + QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText); + QRect normalRect(x, y, availableWidth, normalBoundingRect.height()); + painter->drawText(normalRect, Qt::TextWordWrap | Qt::AlignTop, widgetText); } painter->restore(); @@ -151,10 +159,23 @@ public: return QStyledItemDelegate::sizeHint(option, index); } - QString text = index.data(Qt::DisplayRole).toString(); - QStringList lines = text.split(QLatin1Char('\n')); + // Use separate roles instead of parsing mixed string + QString pathText = index.data(PreferencesSearchController::PathRole).toString(); + QString widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); - if (lines.isEmpty()) { + // Fallback to old method if roles are empty (for compatibility) + if (pathText.isEmpty()) { + QString text = index.data(Qt::DisplayRole).toString(); + QStringList lines = text.split(QLatin1Char('\n')); + if (!lines.isEmpty()) { + pathText = lines.first(); + if (lines.size() > 1) { + widgetText = lines.at(1); + } + } + } + + if (pathText.isEmpty()) { return QStyledItemDelegate::sizeHint(option, index); } @@ -174,14 +195,14 @@ public: int width = 0; int height = 8; // Start with 8 vertical padding (4 top + 4 bottom) - // Calculate height for first line (bold) with wrapping - QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.first()); + // Calculate height for path text (bold) with wrapping + QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, pathText); height += boldBoundingRect.height(); width = qMax(width, boldBoundingRect.width() + 24); // +24 horizontal padding - // Calculate height for remaining lines (normal font) with wrapping - for (int i = 1; i < lines.size(); ++i) { - QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, lines.at(i)); + // Calculate height for widget text (normal font) with wrapping (if present) + if (!widgetText.isEmpty()) { + QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText); height += normalBoundingRect.height(); width = qMax(width, normalBoundingRect.width() + 24); } @@ -1374,7 +1395,8 @@ void PreferencesSearchController::clearHighlights() m_originalStyles.clear(); } -void PreferencesSearchController::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, const QString& pageName, const QString& pageDisplayName, const QString& tabName) +void PreferencesSearchController::collectSearchResults(QWidget* widget, const QString& searchText, const QString& groupName, + const QString& pageName, const QString& pageDisplayName, const QString& tabName) { if (!widget) { return; @@ -1479,8 +1501,18 @@ void PreferencesSearchController::populateSearchResultsList() for (int i = 0; i < m_searchResults.size(); ++i) { const SearchResult& result = m_searchResults.at(i); + + // Create path string + QString pathText = result.tabName + QStringLiteral("/") + result.pageDisplayName; + + // Create item - keep displayText for fallback compatibility QListWidgetItem* item = new QListWidgetItem(result.displayText); - item->setData(Qt::UserRole, i); // Store the index instead of pointer + + // Store path and widget text in separate roles + item->setData(PathRole, pathText); + item->setData(WidgetTextRole, result.matchText); + item->setData(Qt::UserRole, i); // Keep existing index storage + m_searchResultsList->addItem(item); } diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h index 56fcd44fa0..dcedfee0f9 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -57,6 +57,12 @@ private: }; 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; From 2c5aa541e90a5e9452fc915e30b27040c68f07cb Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 14:15:26 +0200 Subject: [PATCH 07/11] Core: Move reusable parts of MixedFontDelegate to separate functions Co-Authored-By: Kacper Donat --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 138 +++++++++++++++----------- 1 file changed, 80 insertions(+), 58 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index ee620ad333..b952ce9ce0 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -77,6 +77,9 @@ using namespace Gui::Dialog; // 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) {} @@ -87,26 +90,19 @@ public: return; } - // Use separate roles instead of parsing mixed string - QString pathText = index.data(PreferencesSearchController::PathRole).toString(); - QString widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); - - if (pathText.isEmpty()) { - QString text = index.data(Qt::DisplayRole).toString(); - QStringList lines = text.split(QLatin1Char('\n')); - if (!lines.isEmpty()) { - pathText = lines.first(); - if (lines.size() > 1) { - widgetText = lines.at(1); - } - } - } + 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 @@ -120,33 +116,19 @@ public: : option.palette.text().color(); painter->setPen(textColor); - // Set up fonts - QFont boldFont = option.font; - boldFont.setBold(true); - QFont normalFont = option.font; - normalFont.setPointSize(normalFont.pointSize() + 2); // make lower text 2 pixels bigger - - QFontMetrics boldFm(boldFont); - QFontMetrics normalFm(normalFont); - - int y = option.rect.top() + 4; // start 4px from top - int x = option.rect.left() + 12; // +12 horizontal padding - int availableWidth = option.rect.width() - 24; // account for left and right padding - // draw path in bold (Tab/Page) with wrapping painter->setFont(boldFont); - QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, pathText); - QRect boldRect(x, y, availableWidth, boldBoundingRect.height()); + QRect boldRect(option.rect.left() + horizontalPadding, option.rect.top() + verticalPadding, + layout.availableWidth, layout.pathHeight); painter->drawText(boldRect, Qt::TextWordWrap | Qt::AlignTop, pathText); - // move y position after the bold text - y += boldBoundingRect.height(); - // draw widget text in normal font (if present) if (!widgetText.isEmpty()) { painter->setFont(normalFont); - QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText); - QRect normalRect(x, y, availableWidth, normalBoundingRect.height()); + QRect normalRect(option.rect.left() + horizontalPadding, + option.rect.top() + verticalPadding + layout.pathHeight, + layout.availableWidth, + layout.widgetHeight); painter->drawText(normalRect, Qt::TextWordWrap | Qt::AlignTop, widgetText); } @@ -158,11 +140,37 @@ public: 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 instead of parsing mixed string - QString pathText = index.data(PreferencesSearchController::PathRole).toString(); - QString widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); - + pathText = index.data(PreferencesSearchController::PathRole).toString(); + widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); + // Fallback to old method if roles are empty (for compatibility) if (pathText.isEmpty()) { QString text = index.data(Qt::DisplayRole).toString(); @@ -174,40 +182,54 @@ public: } } } + } - if (pathText.isEmpty()) { - return QStyledItemDelegate::sizeHint(option, index); - } - - QFont boldFont = option.font; + void createFonts(const QFont& baseFont, QFont& boldFont, QFont& normalFont) const + { + boldFont = baseFont; boldFont.setBold(true); - QFont normalFont = option.font; - normalFont.setPointSize(normalFont.pointSize() + 2); // Make lower text 2 pixels bigger to match paint method + normalFont = baseFont; + normalFont.setPointSize(normalFont.pointSize() + 2); // make lower text 2 pixels bigger + } + + 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 = option.rect.width() - 24; // Account for left and right padding + int availableWidth = containerWidth - horizontalPadding * 2; // account for left and right padding if (availableWidth <= 0) { - availableWidth = 300 - 24; // Fallback to popup width minus padding + constexpr int defaultPopupWidth = 300; + availableWidth = defaultPopupWidth - horizontalPadding * 2; // Fallback to popup width minus padding } - int width = 0; - int height = 8; // Start with 8 vertical padding (4 top + 4 bottom) + // 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 height for path text (bold) with wrapping - QRect boldBoundingRect = boldFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, pathText); - height += boldBoundingRect.height(); - width = qMax(width, boldBoundingRect.width() + 24); // +24 horizontal padding - - // Calculate height for widget text (normal font) with wrapping (if present) + // Calculate dimensions for widget text (normal font, if present) + int widgetHeight = 0; + int widgetWidth = 0; if (!widgetText.isEmpty()) { - QRect normalBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText); - height += normalBoundingRect.height(); - width = qMax(width, normalBoundingRect.width() + 24); + QRect widgetBoundingRect = normalFm.boundingRect(QRect(0, 0, availableWidth, 0), Qt::TextWordWrap, widgetText); + widgetHeight = widgetBoundingRect.height(); + widgetWidth = widgetBoundingRect.width(); } - return QSize(width, height); + 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; } }; From 68464393b92ee5587c081e9333058ec102fdc746 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 15:03:20 +0200 Subject: [PATCH 08/11] Core: Remove displayText field from search box's result Removes displayText from the searchboxes result, as it's being handled differently and there are two other fields that store this previously concatenated information separately. --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 48 ++++++++------------------- src/Gui/Dialogs/DlgPreferencesImp.h | 2 -- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index b952ce9ce0..3214c1aa6a 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -167,21 +167,9 @@ private: void extractTextData(const QModelIndex& index, QString& pathText, QString& widgetText) const { - // Use separate roles instead of parsing mixed string + // Use separate roles - all items should have proper role data pathText = index.data(PreferencesSearchController::PathRole).toString(); widgetText = index.data(PreferencesSearchController::WidgetTextRole).toString(); - - // Fallback to old method if roles are empty (for compatibility) - if (pathText.isEmpty()) { - QString text = index.data(Qt::DisplayRole).toString(); - QStringList lines = text.split(QLatin1Char('\n')); - if (!lines.isEmpty()) { - pathText = lines.first(); - if (lines.size() > 1) { - widgetText = lines.at(1); - } - } - } } void createFonts(const QFont& baseFont, QFont& boldFont, QFont& normalFont) const @@ -1438,7 +1426,6 @@ void PreferencesSearchController::collectSearchResults(QWidget* widget, const QS .groupBoxName = QString(), // No groupbox for page-level match .tabName = tabName, .pageDisplayName = pageDisplayName, - .displayText = formatSearchResultText(result), .isPageLevelMatch = true, // Mark as page-level match .score = pageScore + 2000 // Boost page-level matches }; @@ -1524,15 +1511,20 @@ void PreferencesSearchController::populateSearchResultsList() for (int i = 0; i < m_searchResults.size(); ++i) { const SearchResult& result = m_searchResults.at(i); - // Create path string - QString pathText = result.tabName + QStringLiteral("/") + result.pageDisplayName; - - // Create item - keep displayText for fallback compatibility - QListWidgetItem* item = new QListWidgetItem(result.displayText); + // Create item without setting DisplayRole + QListWidgetItem* item = new QListWidgetItem(); // Store path and widget text in separate roles - item->setData(PathRole, pathText); - item->setData(WidgetTextRole, result.matchText); + 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); @@ -1586,18 +1578,7 @@ QString PreferencesSearchController::findGroupBoxForWidget(QWidget* widget) return QString(); } -QString PreferencesSearchController::formatSearchResultText(const SearchResult& result) -{ - // Format for MixedFontDelegate: First line will be bold, subsequent lines normal - QString text = result.tabName + QStringLiteral("/") + result.pageDisplayName; - - if (!result.isPageLevelMatch) { - // Add the actual finding on the second line - text += QStringLiteral("\n") + result.matchText; - } - - return text; -} + template void PreferencesSearchController::searchWidgetType(QWidget* parentWidget, const QString& searchText, const QString& groupName, @@ -1631,7 +1612,6 @@ void PreferencesSearchController::searchWidgetType(QWidget* parentWidget, const .groupBoxName = findGroupBoxForWidget(widget), .tabName = tabName, .pageDisplayName = pageDisplayName, - .displayText = formatSearchResultText(result), .isPageLevelMatch = false, .score = score }; diff --git a/src/Gui/Dialogs/DlgPreferencesImp.h b/src/Gui/Dialogs/DlgPreferencesImp.h index dcedfee0f9..57c9d367ed 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.h +++ b/src/Gui/Dialogs/DlgPreferencesImp.h @@ -72,7 +72,6 @@ public: QString groupBoxName; QString tabName; // The tab name (like "Display") QString pageDisplayName; // The page display name (like "3D View") - QString displayText; bool isPageLevelMatch = false; // True if this is a page title match int score = 0; // Fuzzy search score for sorting }; @@ -139,7 +138,6 @@ private: // Utility methods QString findGroupBoxForWidget(QWidget* widget); - QString formatSearchResultText(const SearchResult& result); bool fuzzyMatch(const QString& searchText, const QString& targetText, int& score); bool isExactMatch(const QString& searchText, const QString& targetText); From bd231ddb00a6e11e76f91389b899c4fb8195c454 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 15:13:43 +0200 Subject: [PATCH 09/11] Core: Correct font sizes to be smaller in search box in preferences --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 3214c1aa6a..7e1b6fb84b 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -176,9 +176,9 @@ private: { boldFont = baseFont; boldFont.setBold(true); + boldFont.setPointSize(boldFont.pointSize() - 1); // make header smaller like a subtitle - normalFont = baseFont; - normalFont.setPointSize(normalFont.pointSize() + 2); // make lower text 2 pixels bigger + normalFont = baseFont; // keep widget text at normal size } LayoutInfo calculateLayout(const QString& pathText, const QString& widgetText, @@ -1812,7 +1812,9 @@ QString PreferencesSearchController::getHighlightStyleForWidget(QWidget* widget) void PreferencesSearchController::applyHighlightToWidget(QWidget* widget) { - if (!widget) return; + if (!widget) { + return; + } m_originalStyles[widget] = widget->styleSheet(); widget->setStyleSheet(getHighlightStyleForWidget(widget)); From 2bd637206e732cc633db444fb965bf9d23a60b42 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 15:34:52 +0200 Subject: [PATCH 10/11] Core: Handle globalPos for both Qt6 and Qt5 --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 7e1b6fb84b..08e8b476b1 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -1886,11 +1886,15 @@ bool PreferencesSearchController::handlePopupKeyPress(QKeyEvent* keyEvent) bool PreferencesSearchController::isClickOutsidePopup(QMouseEvent* mouseEvent) { - QPointF globalPos = mouseEvent->globalPosition(); +#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.x(), globalPos.y()) && !popupRect.contains(globalPos.x(), globalPos.y()); + return !searchBoxRect.contains(globalPos) && !popupRect.contains(globalPos); } bool DlgPreferencesImp::eventFilter(QObject* obj, QEvent* event) From 74919b4b818ead102b3ce7c0fdaf5d2a23b01a66 Mon Sep 17 00:00:00 2001 From: tetektoza Date: Sun, 22 Jun 2025 15:52:40 +0200 Subject: [PATCH 11/11] Core: Use bypass WM hint for X11 for search list in preferences --- src/Gui/Dialogs/DlgPreferencesImp.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gui/Dialogs/DlgPreferencesImp.cpp b/src/Gui/Dialogs/DlgPreferencesImp.cpp index 08e8b476b1..75d9b1289d 100644 --- a/src/Gui/Dialogs/DlgPreferencesImp.cpp +++ b/src/Gui/Dialogs/DlgPreferencesImp.cpp @@ -1254,7 +1254,7 @@ PreferencesSearchController::PreferencesSearchController(DlgPreferencesImp* pare // Create the search results popup list m_searchResultsList = new QListWidget(m_parentDialog); - m_searchResultsList->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + 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