1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 12:52:06 -04:00
Files
DankMaterialShell/quickshell/Widgets/DankDropdown.qml
Jeff Corcoran 4d649468d5 fix(dropdown): sort fuzzy search results by score and fix empty results on reopen (#2051)
fzf.js relied on stable Array.sort to preserve score ordering, which is
not guaranteed in QML's JS engine. Results appeared in arbitrary order
with low-relevance matches above exact matches. The sort comparator now
explicitly sorts by score descending, with a length-based tiebreaker so
shorter matches rank first when scores are tied.

Also fixed Object.assign mutating the shared defaultOpts object, which
could cause options to leak between Finder instances.

DankDropdown's onOpened handler now reinitializes the search when previous
search text exists, fixing the empty results shown on reopen.

Added resetSearch() for consumers to clear search state externally.
2026-03-23 09:24:51 -04:00

457 lines
17 KiB
QML

import "../Common/fzf.js" as Fzf
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Common
import qs.Widgets
Item {
id: root
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
function checkParentDisablesTransparency() {
let p = parent;
while (p) {
if (p.disablePopupTransparency === true)
return true;
p = p.parent;
}
return false;
}
property string text: ""
property string description: ""
property string currentValue: ""
property var options: []
property var optionIcons: []
property bool enableFuzzySearch: false
property var optionIconMap: ({})
function rebuildIconMap() {
const map = {};
for (let i = 0; i < options.length; i++) {
if (optionIcons.length > i)
map[options[i]] = optionIcons[i];
}
optionIconMap = map;
}
onOptionsChanged: rebuildIconMap()
onOptionIconsChanged: rebuildIconMap()
property int popupWidthOffset: 0
property int maxPopupHeight: 400
property bool openUpwards: false
property int popupWidth: 0
property bool alignPopupRight: false
property int dropdownWidth: 200
property bool compactMode: text === "" && description === ""
property bool addHorizontalPadding: false
property string emptyText: ""
property bool usePopupTransparency: !checkParentDisablesTransparency()
signal valueChanged(string value)
function closeDropdownMenu() {
dropdownMenu.close();
}
function resetSearch() {
searchField.text = "";
dropdownMenu.fzfFinder = null;
dropdownMenu.searchQuery = "";
dropdownMenu.selectedIndex = -1;
}
width: compactMode ? dropdownWidth : parent.width
implicitHeight: compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM)
Component.onDestruction: {
if (dropdownMenu.visible)
dropdownMenu.close();
}
Column {
id: labelColumn
anchors.left: parent.left
anchors.right: dropdown.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: root.addHorizontalPadding ? Theme.spacingM : 0
anchors.rightMargin: Theme.spacingL
spacing: Theme.spacingXS
visible: !root.compactMode
StyledText {
text: root.text
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: root.description
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: description.length > 0
wrapMode: Text.WordWrap
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
Rectangle {
id: dropdown
width: root.compactMode ? parent.width : (root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : root.dropdownWidth))
height: 40
anchors.right: parent.right
anchors.rightMargin: root.addHorizontalPadding && !root.compactMode ? Theme.spacingM : 0
anchors.verticalCenter: parent.verticalCenter
radius: Theme.cornerRadius
color: dropdownArea.containsMouse || dropdownMenu.visible ? Theme.surfaceContainerHigh : (root.usePopupTransparency ? Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) : Theme.surfaceContainer)
border.color: dropdownMenu.visible ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: dropdownMenu.visible ? 2 : 1
MouseArea {
id: dropdownArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (dropdownMenu.visible) {
dropdownMenu.close();
return;
}
dropdownMenu.open();
let currentIndex = root.options.indexOf(root.currentValue);
listView.positionViewAtIndex(currentIndex, ListView.Beginning);
const pos = dropdown.mapToItem(Overlay.overlay, 0, 0);
const popupW = dropdownMenu.width;
const popupH = dropdownMenu.height;
const overlayH = Overlay.overlay.height;
const goUp = root.openUpwards || pos.y + dropdown.height + popupH + 4 > overlayH;
dropdownMenu.x = root.alignPopupRight ? pos.x + dropdown.width - popupW : pos.x - (root.popupWidthOffset / 2);
dropdownMenu.y = goUp ? pos.y - popupH - 4 : pos.y + dropdown.height + 4;
if (root.enableFuzzySearch)
searchField.forceActiveFocus();
}
}
Row {
id: contentRow
anchors.left: parent.left
anchors.right: expandIcon.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: root.optionIconMap[root.currentValue] ?? ""
size: 18
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
visible: name !== ""
}
StyledText {
text: root.currentValue
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
width: contentRow.width - (contentRow.children[0].visible ? contentRow.children[0].width + contentRow.spacing : 0)
elide: Text.ElideRight
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
}
DankIcon {
id: expandIcon
name: dropdownMenu.visible ? "expand_less" : "expand_more"
size: 20
color: Theme.surfaceText
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Theme.spacingS
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
Popup {
id: dropdownMenu
property string searchQuery: ""
property var filteredOptions: {
if (!root.enableFuzzySearch || searchQuery.length === 0)
return root.options;
if (!fzfFinder)
return root.options;
return fzfFinder.find(searchQuery).map(r => r.item);
}
property int selectedIndex: -1
property var fzfFinder: null
function initFinder() {
fzfFinder = new Fzf.Finder(root.options, {
"selector": option => option,
"limit": 50,
"casing": "case-insensitive",
"sort": true,
"tiebreakers": [(a, b, selector) => selector(a.item).length - selector(b.item).length]
});
}
function selectNext() {
if (filteredOptions.length === 0)
return;
selectedIndex = (selectedIndex + 1) % filteredOptions.length;
listView.positionViewAtIndex(selectedIndex, ListView.Contain);
}
function selectPrevious() {
if (filteredOptions.length === 0)
return;
selectedIndex = selectedIndex <= 0 ? filteredOptions.length - 1 : selectedIndex - 1;
listView.positionViewAtIndex(selectedIndex, ListView.Contain);
}
function selectCurrent() {
if (selectedIndex < 0 || selectedIndex >= filteredOptions.length)
return;
root.currentValue = filteredOptions[selectedIndex];
root.valueChanged(filteredOptions[selectedIndex]);
close();
}
onOpened: {
selectedIndex = -1;
if (searchField.text.length > 0) {
initFinder();
searchQuery = searchField.text;
} else {
fzfFinder = null;
searchQuery = "";
}
}
parent: Overlay.overlay
width: root.popupWidth === -1 ? undefined : (root.popupWidth > 0 ? root.popupWidth : (dropdown.width + root.popupWidthOffset))
height: {
let h = root.enableFuzzySearch ? 54 : 0;
if (root.options.length === 0 && root.emptyText !== "")
h += 32;
else
h += Math.min(filteredOptions.length, 10) * 36;
return Math.min(root.maxPopupHeight, h + 16);
}
padding: 0
modal: true
dim: false
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: "transparent"
}
contentItem: Rectangle {
id: contentSurface
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 1)
border.color: Theme.primary
border.width: 2
radius: Theme.cornerRadius
ElevationShadow {
id: shadowLayer
anchors.fill: parent
z: -1
level: Theme.elevationLevel2
fallbackOffset: 4
targetRadius: contentSurface.radius
targetColor: contentSurface.color
borderColor: contentSurface.border.color
borderWidth: contentSurface.border.width
shadowEnabled: Theme.elevationEnabled && SettingsData.popoutElevationEnabled
}
Column {
anchors.fill: parent
anchors.margins: Theme.spacingS
Rectangle {
id: searchContainer
width: parent.width
height: 42
visible: root.enableFuzzySearch
radius: Theme.cornerRadius
color: root.usePopupTransparency ? Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) : Theme.surfaceContainerHigh
DankTextField {
id: searchField
anchors.fill: parent
anchors.margins: 1
placeholderText: I18n.tr("Search...")
topPadding: Theme.spacingS
bottomPadding: Theme.spacingS
onTextChanged: searchDebounce.restart()
Keys.onDownPressed: dropdownMenu.selectNext()
Keys.onUpPressed: dropdownMenu.selectPrevious()
Keys.onReturnPressed: dropdownMenu.selectCurrent()
Keys.onEnterPressed: dropdownMenu.selectCurrent()
Keys.onPressed: event => {
if (!(event.modifiers & Qt.ControlModifier))
return;
switch (event.key) {
case Qt.Key_N:
case Qt.Key_J:
dropdownMenu.selectNext();
event.accepted = true;
break;
case Qt.Key_P:
case Qt.Key_K:
dropdownMenu.selectPrevious();
event.accepted = true;
break;
}
}
Timer {
id: searchDebounce
interval: 50
onTriggered: {
if (!dropdownMenu.fzfFinder)
dropdownMenu.initFinder();
dropdownMenu.searchQuery = searchField.text;
dropdownMenu.selectedIndex = -1;
}
}
}
}
Item {
width: 1
height: Theme.spacingXS
visible: root.enableFuzzySearch
}
Item {
width: parent.width
height: 32
visible: root.options.length === 0 && root.emptyText !== ""
StyledText {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: root.emptyText
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignLeft
}
}
DankListView {
id: listView
width: parent.width
height: parent.height - (root.enableFuzzySearch ? searchContainer.height + Theme.spacingXS : 0) - (root.options.length === 0 && root.emptyText !== "" ? 32 : 0)
clip: true
visible: root.options.length > 0
model: ScriptModel {
values: dropdownMenu.filteredOptions
}
spacing: 2
interactive: true
flickDeceleration: 1500
maximumFlickVelocity: 2000
boundsBehavior: Flickable.DragAndOvershootBounds
boundsMovement: Flickable.FollowBoundsBehavior
pressDelay: 0
flickableDirection: Flickable.VerticalFlick
delegate: Rectangle {
id: delegateRoot
required property var modelData
required property int index
property bool isSelected: dropdownMenu.selectedIndex === index
property bool isCurrentValue: root.currentValue === modelData
property string iconName: root.optionIconMap[modelData] ?? ""
width: ListView.view.width
height: 32
radius: Theme.cornerRadius
color: isSelected ? Theme.primaryHover : optionArea.containsMouse ? Theme.primaryHoverLight : "transparent"
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankIcon {
name: delegateRoot.iconName
size: 18
color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText
visible: name !== ""
}
StyledText {
anchors.verticalCenter: parent.verticalCenter
text: delegateRoot.modelData
font.pixelSize: Theme.fontSizeMedium
color: delegateRoot.isCurrentValue ? Theme.primary : Theme.surfaceText
font.weight: delegateRoot.isCurrentValue ? Font.Medium : Font.Normal
width: root.popupWidth > 0 ? undefined : (delegateRoot.width - parent.x - Theme.spacingS * 2)
elide: root.popupWidth > 0 ? Text.ElideNone : Text.ElideRight
wrapMode: Text.NoWrap
horizontalAlignment: Text.AlignLeft
}
}
MouseArea {
id: optionArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.currentValue = delegateRoot.modelData;
root.valueChanged(delegateRoot.modelData);
root.closeDropdownMenu();
}
}
}
}
}
}
}
}