mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-04 04:42:05 -04:00
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.
457 lines
17 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|