NSearchableComboBox: created, uses fuzzy find

GeneralTab: replace NComboBox with NSearchableComboBox
This commit is contained in:
Ly-sec
2025-09-14 13:22:17 +02:00
parent ccd7458ea3
commit a86a0d33c1
2 changed files with 260 additions and 3 deletions

View File

@@ -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) {

View File

@@ -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()
}
})
}
}
}
}
}