From a86a0d33c19552138b85127d7c096283a9d23fd3 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 14 Sep 2025 13:22:17 +0200 Subject: [PATCH 1/2] NSearchableComboBox: created, uses fuzzy find GeneralTab: replace NComboBox with NSearchableComboBox --- Modules/SettingsPanel/Tabs/GeneralTab.qml | 9 +- Widgets/NSearchableComboBox.qml | 254 ++++++++++++++++++++++ 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 Widgets/NSearchableComboBox.qml diff --git a/Modules/SettingsPanel/Tabs/GeneralTab.qml b/Modules/SettingsPanel/Tabs/GeneralTab.qml index aed5758..8e61387 100644 --- a/Modules/SettingsPanel/Tabs/GeneralTab.qml +++ b/Modules/SettingsPanel/Tabs/GeneralTab.qml @@ -200,12 +200,13 @@ ColumnLayout { spacing: Style.marginL * scaling Layout.fillWidth: true - NComboBox { + NSearchableComboBox { label: "Default Font" description: "Main font used throughout the interface." model: FontService.availableFonts currentKey: Settings.data.ui.fontDefault placeholder: "Select default font..." + searchPlaceholder: "Search fonts..." popupHeight: 420 * scaling minimumWidth: 300 * scaling onSelected: function (key) { @@ -213,12 +214,13 @@ ColumnLayout { } } - NComboBox { + NSearchableComboBox { label: "Fixed Width Font" description: "Monospace font used for terminal and code display." model: FontService.monospaceFonts currentKey: Settings.data.ui.fontFixed placeholder: "Select monospace font..." + searchPlaceholder: "Search monospace fonts..." popupHeight: 320 * scaling minimumWidth: 300 * scaling onSelected: function (key) { @@ -226,12 +228,13 @@ ColumnLayout { } } - NComboBox { + NSearchableComboBox { label: "Billboard Font" description: "Large font used for clocks and prominent displays." model: FontService.displayFonts currentKey: Settings.data.ui.fontBillboard placeholder: "Select display font..." + searchPlaceholder: "Search display fonts..." popupHeight: 320 * scaling minimumWidth: 300 * scaling onSelected: function (key) { diff --git a/Widgets/NSearchableComboBox.qml b/Widgets/NSearchableComboBox.qml new file mode 100644 index 0000000..380162a --- /dev/null +++ b/Widgets/NSearchableComboBox.qml @@ -0,0 +1,254 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services +import qs.Widgets +import "../Helpers/FuzzySort.js" as Fuzzysort + +RowLayout { + id: root + + property real minimumWidth: 280 * scaling + property real popupHeight: 180 * scaling + + property string label: "" + property string description: "" + property ListModel model: { + + } + property string currentKey: "" + property string placeholder: "" + property string searchPlaceholder: "Search..." + + readonly property real preferredHeight: Style.baseWidgetSize * 1.1 * scaling + + signal selected(string key) + + spacing: Style.marginL * scaling + Layout.fillWidth: true + + // Filtered model for search results + property ListModel filteredModel: ListModel {} + property string searchText: "" + + function findIndexByKey(key) { + for (var i = 0; i < root.model.count; i++) { + if (root.model.get(i).key === key) { + return i + } + } + return -1 + } + + function findIndexByKeyInFiltered(key) { + for (var i = 0; i < root.filteredModel.count; i++) { + if (root.filteredModel.get(i).key === key) { + return i + } + } + return -1 + } + + function filterModel() { + filteredModel.clear() + + if (searchText.trim() === "") { + // If no search text, show all items + for (var i = 0; i < root.model.count; i++) { + filteredModel.append(root.model.get(i)) + } + } else { + // Convert ListModel to array for fuzzy search + var items = [] + for (var i = 0; i < root.model.count; i++) { + items.push(root.model.get(i)) + } + + // Use fuzzy search if available, fallback to simple search + if (typeof Fuzzysort !== 'undefined') { + var fuzzyResults = Fuzzysort.go(searchText, items, { + "key": "name", + "threshold": -1000, + "limit": 50 + }) + + // Add results in order of relevance + for (var j = 0; j < fuzzyResults.length; j++) { + filteredModel.append(fuzzyResults[j].obj) + } + } else { + // Fallback to simple search + var searchLower = searchText.toLowerCase() + for (var i = 0; i < items.length; i++) { + var item = items[i] + if (item.name.toLowerCase().includes(searchLower)) { + filteredModel.append(item) + } + } + } + } + } + + // Update filtered model when search text or original model changes + onSearchTextChanged: filterModel() + onModelChanged: filterModel() + + NLabel { + label: root.label + description: root.description + } + + Item { + Layout.fillWidth: true + } + + ComboBox { + id: combo + + Layout.minimumWidth: root.minimumWidth + Layout.preferredHeight: root.preferredHeight + model: filteredModel + currentIndex: findIndexByKeyInFiltered(currentKey) + onActivated: { + if (combo.currentIndex >= 0 && combo.currentIndex < filteredModel.count) { + root.selected(filteredModel.get(combo.currentIndex).key) + } + } + + background: Rectangle { + implicitWidth: Style.baseWidgetSize * 3.75 * scaling + implicitHeight: preferredHeight + color: Color.mSurface + border.color: combo.activeFocus ? Color.mSecondary : Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + radius: Style.radiusM * scaling + + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + contentItem: NText { + leftPadding: Style.marginL * scaling + rightPadding: combo.indicator.width + Style.marginL * scaling + font.pointSize: Style.fontSizeM * scaling + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + color: (combo.currentIndex >= 0 && combo.currentIndex < filteredModel.count) ? Color.mOnSurface : Color.mOnSurfaceVariant + text: (combo.currentIndex >= 0 && combo.currentIndex < filteredModel.count) ? filteredModel.get(combo.currentIndex).name : root.placeholder + } + + indicator: NIcon { + x: combo.width - width - Style.marginM * scaling + y: combo.topPadding + (combo.availableHeight - height) / 2 + icon: "caret-down" + font.pointSize: Style.fontSizeL * scaling + } + + popup: Popup { + y: combo.height + width: combo.width + height: root.popupHeight + 60 * scaling + padding: Style.marginM * scaling + + contentItem: ColumnLayout { + spacing: Style.marginS * scaling + + // Search input + NTextInput { + id: searchInput + Layout.fillWidth: true + placeholderText: root.searchPlaceholder + text: root.searchText + onTextChanged: root.searchText = text + fontSize: Style.fontSizeS * scaling + } + + // Font list + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: combo.popup.visible ? filteredModel : null + ScrollIndicator.vertical: ScrollIndicator {} + + delegate: ItemDelegate { + width: listView.width + hoverEnabled: true + highlighted: ListView.view.currentIndex === index + + onHoveredChanged: { + if (hovered) { + ListView.view.currentIndex = index + } + } + + onClicked: { + root.selected(filteredModel.get(index).key) + combo.currentIndex = root.findIndexByKeyInFiltered(filteredModel.get(index).key) + combo.popup.close() + } + + contentItem: NText { + text: name + font.pointSize: Style.fontSizeM * scaling + color: highlighted ? Color.mSurface : Color.mOnSurface + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + width: listView.width - Style.marginM * scaling * 2 + color: highlighted ? Color.mTertiary : Color.transparent + radius: Style.radiusS * scaling + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + } + } + } + + background: Rectangle { + color: Color.mSurfaceVariant + border.color: Color.mOutline + border.width: Math.max(1, Style.borderS * scaling) + radius: Style.radiusM * scaling + } + } + + // Update the currentIndex if the currentKey is changed externally + Connections { + target: root + function onCurrentKeyChanged() { + combo.currentIndex = root.findIndexByKeyInFiltered(currentKey) + } + } + + // Focus search input when popup opens + Connections { + target: combo.popup + function onVisibleChanged() { + if (combo.popup.visible) { + // Small delay to ensure the popup is fully rendered + Qt.callLater(function () { + if (searchInput && searchInput.inputItem) { + searchInput.inputItem.forceActiveFocus() + } + }) + } + } + } + } +} From aadbc9596d3b62cf28299f364a1b03423c6c9ac7 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 14 Sep 2025 13:25:02 +0200 Subject: [PATCH 2/2] NSearchableComboBox: small layout change --- Widgets/NSearchableComboBox.qml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Widgets/NSearchableComboBox.qml b/Widgets/NSearchableComboBox.qml index 380162a..3f91321 100644 --- a/Widgets/NSearchableComboBox.qml +++ b/Widgets/NSearchableComboBox.qml @@ -90,7 +90,6 @@ RowLayout { } } - // Update filtered model when search text or original model changes onSearchTextChanged: filterModel() onModelChanged: filterModel() @@ -207,7 +206,7 @@ RowLayout { } background: Rectangle { - width: listView.width - Style.marginM * scaling * 2 + width: listView.width * scaling color: highlighted ? Color.mTertiary : Color.transparent radius: Style.radiusS * scaling Behavior on color {