pragma ComponentBehavior: Bound import QtQuick import qs.Common import qs.Modals.Spotlight import qs.Modules.AppDrawer import qs.Services import qs.Widgets Item { id: spotlightKeyHandler LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true property alias appLauncher: appLauncher property alias searchField: searchField property alias fileSearchController: fileSearchController property alias resultsView: resultsView property var parentModal: null property string searchMode: "apps" property bool usePopupContextMenu: false property bool editMode: false property var editingApp: null property string editAppId: "" function resetScroll() { if (searchMode === "apps") { resultsView.resetScroll(); } else { fileSearchResults.resetScroll(); } } function updateSearchMode() { if (searchField.text.startsWith("/")) { if (searchMode !== "files") { searchMode = "files"; } const query = searchField.text.substring(1); fileSearchController.searchQuery = query; } else { if (searchMode !== "apps") { searchMode = "apps"; fileSearchController.reset(); appLauncher.searchQuery = searchField.text; } } } function openEditMode(app) { if (!app) return; editingApp = app; editAppId = app.id || app.execString || app.exec || ""; const existing = SessionData.getAppOverride(editAppId); editNameField.text = existing?.name || ""; editIconField.text = existing?.icon || ""; editCommentField.text = existing?.comment || ""; editEnvVarsField.text = existing?.envVars || ""; editExtraFlagsField.text = existing?.extraFlags || ""; editMode = true; Qt.callLater(() => editNameField.forceActiveFocus()); } function closeEditMode() { editMode = false; editingApp = null; editAppId = ""; Qt.callLater(() => searchField.forceActiveFocus()); } function saveAppOverride() { const override = {}; if (editNameField.text.trim()) override.name = editNameField.text.trim(); if (editIconField.text.trim()) override.icon = editIconField.text.trim(); if (editCommentField.text.trim()) override.comment = editCommentField.text.trim(); if (editEnvVarsField.text.trim()) override.envVars = editEnvVarsField.text.trim(); if (editExtraFlagsField.text.trim()) override.extraFlags = editExtraFlagsField.text.trim(); SessionData.setAppOverride(editAppId, override); closeEditMode(); } function resetAppOverride() { SessionData.clearAppOverride(editAppId); closeEditMode(); } onSearchModeChanged: { if (searchMode === "files") { appLauncher.keyboardNavigationActive = false; } else { fileSearchController.keyboardNavigationActive = false; } } anchors.fill: parent focus: true clip: false Keys.onPressed: event => { if (editMode) { if (event.key === Qt.Key_Escape) { closeEditMode(); event.accepted = true; } return; } if (event.key === Qt.Key_Escape) { if (parentModal) parentModal.hide(); event.accepted = true; } else if (event.key === Qt.Key_Down) { if (searchMode === "apps") { appLauncher.selectNext(); } else { fileSearchController.selectNext(); } event.accepted = true; } else if (event.key === Qt.Key_Up) { if (searchMode === "apps") { appLauncher.selectPrevious(); } else { fileSearchController.selectPrevious(); } event.accepted = true; } else if (event.key === Qt.Key_Right && searchMode === "apps" && appLauncher.viewMode === "grid") { I18n.isRtl ? appLauncher.selectPreviousInRow() : appLauncher.selectNextInRow(); event.accepted = true; } else if (event.key === Qt.Key_Left && searchMode === "apps" && appLauncher.viewMode === "grid") { I18n.isRtl ? appLauncher.selectNextInRow() : appLauncher.selectPreviousInRow(); event.accepted = true; } else if (event.key == Qt.Key_J && event.modifiers & Qt.ControlModifier) { if (searchMode === "apps") { appLauncher.selectNext(); } else { fileSearchController.selectNext(); } event.accepted = true; } else if (event.key == Qt.Key_K && event.modifiers & Qt.ControlModifier) { if (searchMode === "apps") { appLauncher.selectPrevious(); } else { fileSearchController.selectPrevious(); } event.accepted = true; } else if (event.key == Qt.Key_L && event.modifiers & Qt.ControlModifier && searchMode === "apps" && appLauncher.viewMode === "grid") { I18n.isRtl ? appLauncher.selectPreviousInRow() : appLauncher.selectNextInRow(); event.accepted = true; } else if (event.key == Qt.Key_H && event.modifiers & Qt.ControlModifier && searchMode === "apps" && appLauncher.viewMode === "grid") { I18n.isRtl ? appLauncher.selectNextInRow() : appLauncher.selectPreviousInRow(); event.accepted = true; } else if (event.key === Qt.Key_Tab) { if (searchMode === "apps") { if (appLauncher.viewMode === "grid") { appLauncher.selectNextInRow(); } else { appLauncher.selectNext(); } } else { fileSearchController.selectNext(); } event.accepted = true; } else if (event.key === Qt.Key_Backtab) { if (searchMode === "apps") { if (appLauncher.viewMode === "grid") { appLauncher.selectPreviousInRow(); } else { appLauncher.selectPrevious(); } } else { fileSearchController.selectPrevious(); } event.accepted = true; } else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) { if (searchMode === "apps") { if (appLauncher.viewMode === "grid") { appLauncher.selectNextInRow(); } else { appLauncher.selectNext(); } } else { fileSearchController.selectNext(); } event.accepted = true; } else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) { if (searchMode === "apps") { if (appLauncher.viewMode === "grid") { appLauncher.selectPreviousInRow(); } else { appLauncher.selectPrevious(); } } else { fileSearchController.selectPrevious(); } event.accepted = true; } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (searchMode === "apps") { appLauncher.launchSelected(); } else if (searchMode === "files") { fileSearchController.openSelected(); } event.accepted = true; } else if (event.key === Qt.Key_Menu || event.key == Qt.Key_F10) { if (searchMode === "apps" && appLauncher.model.count > 0) { const selectedApp = appLauncher.model.get(appLauncher.selectedIndex); const menu = usePopupContextMenu ? popupContextMenu : layerContextMenuLoader.item; if (selectedApp && menu && resultsView) { const itemPos = resultsView.getSelectedItemPosition(); const contentPos = resultsView.mapToItem(spotlightKeyHandler, itemPos.x, itemPos.y); menu.show(contentPos.x, contentPos.y, selectedApp, true); } } event.accepted = true; } } AppLauncher { id: appLauncher viewMode: SettingsData.spotlightModalViewMode gridColumns: SettingsData.appLauncherGridColumns onAppLaunched: () => { if (parentModal) parentModal.hide(); if (SettingsData.spotlightCloseNiriOverview && NiriService.inOverview) { NiriService.toggleOverview(); } } onViewModeSelected: mode => { SettingsData.set("spotlightModalViewMode", mode); } } FileSearchController { id: fileSearchController onFileOpened: () => { if (parentModal) parentModal.hide(); if (SettingsData.spotlightCloseNiriOverview && NiriService.inOverview) { NiriService.toggleOverview(); } } } SpotlightContextMenuPopup { id: popupContextMenu parent: spotlightKeyHandler appLauncher: spotlightKeyHandler.appLauncher parentHandler: spotlightKeyHandler searchField: spotlightKeyHandler.searchField visible: false z: 1000 } MouseArea { anchors.fill: parent visible: usePopupContextMenu && popupContextMenu.visible hoverEnabled: true z: 999 onClicked: popupContextMenu.hide() } Loader { id: layerContextMenuLoader active: !spotlightKeyHandler.usePopupContextMenu asynchronous: false sourceComponent: Component { SpotlightContextMenu { appLauncher: spotlightKeyHandler.appLauncher parentHandler: spotlightKeyHandler parentModal: spotlightKeyHandler.parentModal } } } Connections { target: parentModal function onSpotlightOpenChanged() { if (parentModal && !parentModal.spotlightOpen) { if (layerContextMenuLoader.item) layerContextMenuLoader.item.hide(); popupContextMenu.hide(); if (editMode) closeEditMode(); } } enabled: parentModal !== null } Connections { target: popupContextMenu function onEditAppRequested(app) { spotlightKeyHandler.openEditMode(app); } } Connections { target: layerContextMenuLoader.item function onEditAppRequested(app) { spotlightKeyHandler.openEditMode(app); } enabled: layerContextMenuLoader.item !== null } Column { anchors.fill: parent anchors.margins: Theme.spacingM spacing: Theme.spacingM clip: false visible: !editMode Item { id: searchRow width: parent.width - Theme.spacingS * 2 height: 56 anchors.horizontalCenter: parent.horizontalCenter DankTextField { id: searchField anchors.left: parent.left anchors.right: buttonsContainer.left anchors.rightMargin: Theme.spacingM cornerRadius: Theme.cornerRadius backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) normalBorderColor: Theme.outlineMedium focusedBorderColor: Theme.primary leftIconName: searchMode === "files" ? "folder" : "search" leftIconSize: Theme.iconSize leftIconColor: Theme.surfaceVariantText leftIconFocusedColor: Theme.primary showClearButton: true textColor: Theme.surfaceText font.pixelSize: Theme.fontSizeLarge enabled: parentModal ? parentModal.spotlightOpen : true placeholderText: "" ignoreLeftRightKeys: appLauncher.viewMode !== "list" ignoreTabKeys: true keyForwardTargets: [spotlightKeyHandler] onTextChanged: { if (searchMode === "apps") appLauncher.searchQuery = text; } onTextEdited: updateSearchMode() Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { if (parentModal) parentModal.hide(); event.accepted = true; } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length > 0) { if (searchMode === "apps") { if (appLauncher.keyboardNavigationActive && appLauncher.model.count > 0) appLauncher.launchSelected(); else if (appLauncher.model.count > 0) appLauncher.launchApp(appLauncher.model.get(0)); } else if (searchMode === "files") { if (fileSearchController.model.count > 0) fileSearchController.openSelected(); } event.accepted = true; } else if (event.key === Qt.Key_Down || event.key === Qt.Key_Up || event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab || ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && text.length === 0)) { event.accepted = false; } } } Item { id: buttonsContainer width: viewModeButtons.visible ? viewModeButtons.width : (fileSearchButtons.visible ? fileSearchButtons.width : 0) height: 36 anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter Row { id: viewModeButtons spacing: Theme.spacingXS visible: searchMode === "apps" anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter Rectangle { width: 36 height: 36 radius: Theme.cornerRadius color: appLauncher.viewMode === "list" ? Theme.primaryHover : listViewArea.containsMouse ? Theme.surfaceHover : "transparent" DankIcon { anchors.centerIn: parent name: "view_list" size: 18 color: appLauncher.viewMode === "list" ? Theme.primary : Theme.surfaceText } MouseArea { id: listViewArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: appLauncher.setViewMode("list") } } Rectangle { width: 36 height: 36 radius: Theme.cornerRadius color: appLauncher.viewMode === "grid" ? Theme.primaryHover : gridViewArea.containsMouse ? Theme.surfaceHover : "transparent" DankIcon { anchors.centerIn: parent name: "grid_view" size: 18 color: appLauncher.viewMode === "grid" ? Theme.primary : Theme.surfaceText } MouseArea { id: gridViewArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: appLauncher.setViewMode("grid") } } } Row { id: fileSearchButtons spacing: Theme.spacingXS visible: searchMode === "files" anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter Rectangle { id: filenameFilterButton width: 36 height: 36 radius: Theme.cornerRadius color: fileSearchController.searchField === "filename" ? Theme.primaryHover : filenameFilterArea.containsMouse ? Theme.surfaceHover : "transparent" DankIcon { anchors.centerIn: parent name: "title" size: 18 color: fileSearchController.searchField === "filename" ? Theme.primary : Theme.surfaceText } MouseArea { id: filenameFilterArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: fileSearchController.searchField = "filename" onEntered: { filenameTooltipLoader.active = true; Qt.callLater(() => { if (filenameTooltipLoader.item) { const p = mapToItem(null, width / 2, height + Theme.spacingXS); filenameTooltipLoader.item.show(I18n.tr("Search filenames"), p.x, p.y, null); } }); } onExited: { if (filenameTooltipLoader.item) filenameTooltipLoader.item.hide(); filenameTooltipLoader.active = false; } } } Rectangle { id: contentFilterButton width: 36 height: 36 radius: Theme.cornerRadius color: fileSearchController.searchField === "body" ? Theme.primaryHover : contentFilterArea.containsMouse ? Theme.surfaceHover : "transparent" DankIcon { anchors.centerIn: parent name: "description" size: 18 color: fileSearchController.searchField === "body" ? Theme.primary : Theme.surfaceText } MouseArea { id: contentFilterArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: fileSearchController.searchField = "body" onEntered: { contentTooltipLoader.active = true; Qt.callLater(() => { if (contentTooltipLoader.item) { const p = mapToItem(null, width / 2, height + Theme.spacingXS); contentTooltipLoader.item.show(I18n.tr("Search file contents"), p.x, p.y, null); } }); } onExited: { if (contentTooltipLoader.item) contentTooltipLoader.item.hide(); contentTooltipLoader.active = false; } } } } } } Item { width: parent.width height: parent.height - y opacity: parentModal?.isClosing ? 0 : 1 SpotlightResults { id: resultsView anchors.fill: parent appLauncher: spotlightKeyHandler.appLauncher visible: searchMode === "apps" onItemRightClicked: (index, modelData, mouseX, mouseY) => { const menu = usePopupContextMenu ? popupContextMenu : layerContextMenuLoader.item; if (menu?.show) { const isPopup = menu.contentItem !== undefined; if (isPopup) { const localPos = popupContextMenu.parent.mapFromItem(null, mouseX, mouseY); menu.show(localPos.x, localPos.y, modelData, false); } else { menu.show(mouseX, mouseY, modelData, false); } } } } FileSearchResults { id: fileSearchResults anchors.fill: parent fileSearchController: spotlightKeyHandler.fileSearchController visible: searchMode === "files" } } } FocusScope { id: editView anchors.fill: parent anchors.margins: Theme.spacingM visible: editMode focus: editMode Keys.onPressed: event => { switch (event.key) { case Qt.Key_Escape: closeEditMode(); event.accepted = true; return; case Qt.Key_Return: case Qt.Key_Enter: if (event.modifiers & Qt.ControlModifier) { saveAppOverride(); event.accepted = true; } return; case Qt.Key_S: if (event.modifiers & Qt.ControlModifier) { saveAppOverride(); event.accepted = true; } return; case Qt.Key_R: if ((event.modifiers & Qt.ControlModifier) && SessionData.getAppOverride(editAppId) !== null) { resetAppOverride(); event.accepted = true; } return; } } Column { anchors.fill: parent spacing: Theme.spacingM Row { width: parent.width spacing: Theme.spacingM Rectangle { width: 40 height: 40 radius: Theme.cornerRadius color: backButtonArea.containsMouse ? Theme.surfaceHover : "transparent" DankIcon { anchors.centerIn: parent name: "arrow_back" size: 20 color: Theme.surfaceText } MouseArea { id: backButtonArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: closeEditMode() } } Image { width: 40 height: 40 source: editingApp?.icon ? "image://icon/" + editingApp.icon : "image://icon/application-x-executable" sourceSize.width: 40 sourceSize.height: 40 fillMode: Image.PreserveAspectFit anchors.verticalCenter: parent.verticalCenter } Column { anchors.verticalCenter: parent.verticalCenter spacing: 2 StyledText { text: I18n.tr("Edit App") font.pixelSize: Theme.fontSizeLarge color: Theme.surfaceText font.weight: Font.Medium } StyledText { text: editingApp?.name || "" font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText } } } Rectangle { width: parent.width height: 1 color: Theme.outlineMedium } Flickable { width: parent.width height: parent.height - y - buttonsRow.height - Theme.spacingM contentHeight: editFieldsColumn.height clip: true boundsBehavior: Flickable.StopAtBounds Column { id: editFieldsColumn width: parent.width spacing: Theme.spacingS Column { width: parent.width spacing: 4 StyledText { text: I18n.tr("Name") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: Font.Medium } DankTextField { id: editNameField width: parent.width placeholderText: editingApp?.name || "" keyNavigationTab: editIconField keyNavigationBacktab: editExtraFlagsField } } Column { width: parent.width spacing: 4 StyledText { text: I18n.tr("Icon") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: Font.Medium } DankTextField { id: editIconField width: parent.width placeholderText: editingApp?.icon || "" keyNavigationTab: editCommentField keyNavigationBacktab: editNameField } } Column { width: parent.width spacing: 4 StyledText { text: I18n.tr("Description") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: Font.Medium } DankTextField { id: editCommentField width: parent.width placeholderText: editingApp?.comment || "" keyNavigationTab: editEnvVarsField keyNavigationBacktab: editIconField } } Column { width: parent.width spacing: 4 StyledText { text: I18n.tr("Environment Variables") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: Font.Medium } StyledText { text: "KEY=value KEY2=value2" font.pixelSize: Theme.fontSizeSmall - 1 color: Theme.surfaceVariantText } DankTextField { id: editEnvVarsField width: parent.width placeholderText: "VAR=value" keyNavigationTab: editExtraFlagsField keyNavigationBacktab: editCommentField } } Column { width: parent.width spacing: 4 StyledText { text: I18n.tr("Extra Arguments") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceText font.weight: Font.Medium } DankTextField { id: editExtraFlagsField width: parent.width placeholderText: "--flag --option=value" keyNavigationTab: editNameField keyNavigationBacktab: editEnvVarsField } } } } Row { id: buttonsRow anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingM Rectangle { id: resetButton width: 90 height: 40 radius: Theme.cornerRadius color: resetButtonArea.containsMouse ? Theme.surfacePressed : Theme.surfaceVariantAlpha visible: SessionData.getAppOverride(editAppId) !== null StyledText { text: I18n.tr("Reset") font.pixelSize: Theme.fontSizeMedium color: Theme.error font.weight: Font.Medium anchors.centerIn: parent } MouseArea { id: resetButtonArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: resetAppOverride() } } Rectangle { id: cancelButton width: 90 height: 40 radius: Theme.cornerRadius color: cancelButtonArea.containsMouse ? Theme.surfacePressed : Theme.surfaceVariantAlpha StyledText { text: I18n.tr("Cancel") font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceText font.weight: Font.Medium anchors.centerIn: parent } MouseArea { id: cancelButtonArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: closeEditMode() } } Rectangle { id: saveButton width: 90 height: 40 radius: Theme.cornerRadius color: saveButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.9) : Theme.primary StyledText { text: I18n.tr("Save") font.pixelSize: Theme.fontSizeMedium color: Theme.primaryText font.weight: Font.Medium anchors.centerIn: parent } MouseArea { id: saveButtonArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: saveAppOverride() } } } } } Loader { id: filenameTooltipLoader active: false sourceComponent: DankTooltip {} } Loader { id: contentTooltipLoader active: false sourceComponent: DankTooltip {} } }