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