diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 10c4bd0a..b964a2dc 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -258,6 +258,8 @@ Singleton { onFrameLauncherEmergeSideChanged: saveSettings() property bool frameLauncherArcExtender: false onFrameLauncherArcExtenderChanged: saveSettings() + property bool frameUseSpotlightLauncher: false + onFrameUseSpotlightLauncherChanged: saveSettings() readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top" property string frameMode: "connected" onFrameModeChanged: saveSettings() @@ -447,6 +449,7 @@ Singleton { property bool dankLauncherV2UnloadOnClose: false property bool dankLauncherV2IncludeFilesInAll: false property bool dankLauncherV2IncludeFoldersInAll: false + property string launcherStyle: "full" property string _legacyWeatherLocation: "New York, NY" property string _legacyWeatherCoordinates: "40.7128,-74.0060" diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index d919e6a7..f9938106 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -213,6 +213,7 @@ var SPEC = { dankLauncherV2UnloadOnClose: { def: false }, dankLauncherV2IncludeFilesInAll: { def: false }, dankLauncherV2IncludeFoldersInAll: { def: false }, + launcherStyle: { def: "full" }, useAutoLocation: { def: false }, weatherEnabled: { def: true }, @@ -572,6 +573,7 @@ var SPEC = { frameCloseGaps: { def: true }, frameLauncherEmergeSide: { def: "bottom" }, frameLauncherArcExtender: { def: false }, + frameUseSpotlightLauncher: { def: false }, frameMode: { def: "connected" } }; diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml index 3657e948..98f61fcd 100644 --- a/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2Modal.qml @@ -61,7 +61,8 @@ Item { impl.item.toggleWithMode(mode); } - readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp + readonly property bool useSpotlightBackend: SettingsData.connectedFrameModeActive ? SettingsData.frameUseSpotlightLauncher : SettingsData.launcherStyle === "spotlight" + readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp) property var _resolvedBackend: null Component.onCompleted: _resolvedBackend = _desiredBackend @@ -71,6 +72,12 @@ Item { function onConnectedFrameModeActiveChanged() { root._maybeResolveBackend(); } + function onFrameUseSpotlightLauncherChanged() { + root._maybeResolveBackend(); + } + function onLauncherStyleChanged() { + root._maybeResolveBackend(); + } } // Defer Loader source-component swap until impl is fully closed; avoids @@ -100,6 +107,11 @@ Item { DankLauncherV2ModalConnected {} } + Component { + id: spotlightComp + DankLauncherV2ModalSpotlight {} + } + function _wireBackend(it) { if (!it) return; diff --git a/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml new file mode 100644 index 00000000..3a08ccf3 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml @@ -0,0 +1,461 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + readonly property var log: Log.scoped("DankLauncherV2ModalSpotlight") + + property var modalHandle: root + + visible: false + + property bool spotlightOpen: false + property bool keyboardActive: false + property bool contentVisible: false + property var spotlightContent: contentLoader.item + property bool openedFromOverview: false + property bool isClosing: false + property bool _pendingInitialize: false + property string _pendingQuery: "" + property string _pendingMode: "" + + readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab + readonly property var effectiveScreen: launcherWindow.screen + readonly property real screenWidth: effectiveScreen?.width ?? 1920 + readonly property real screenHeight: effectiveScreen?.height ?? 1080 + readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1 + + readonly property int _openDuration: 80 + readonly property int _closeDuration: 70 + readonly property int _motionDuration: 90 + + // Connected frame mode clamps the centered surface inside frame insets. + readonly property bool frameConnected: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences) + + function _frameEdgeInset(side) { + if (!effectiveScreen || !frameConnected) + return 0; + return SettingsData.frameEdgeInsetForSide(effectiveScreen, side); + } + + // Fixed 680px width, centered horizontally (respecting frame insets) + readonly property int modalWidth: Math.min(680, screenWidth - 80) + readonly property real modalX: { + const insetL = _frameEdgeInset("left"); + const insetR = _frameEdgeInset("right"); + const usable = Math.max(0, screenWidth - insetL - insetR); + return insetL + Math.max(0, (usable - modalWidth) / 2); + } + // Keep the search bar centered; results expand downward unless the screen edge clamps it. + readonly property real modalY: { + const insetT = _frameEdgeInset("top"); + const insetB = _frameEdgeInset("bottom"); + const searchBarH = 56; + const usableH = Math.max(searchBarH, screenHeight - insetT - insetB); + const preferred = insetT + Math.max(0, usableH * 0.33 - searchBarH / 2); + const maxY = Math.max(insetT, screenHeight - insetB - _contentImplicitH); + return Math.max(insetT, Math.min(preferred, maxY)); + } + + // Dynamic height from content + readonly property real _contentImplicitH: contentLoader.item?.implicitHeight ?? 56 + readonly property int modalHeight: _contentImplicitH + + readonly property var shadowLevel: Theme.elevationLevel3 + readonly property real shadowFallbackOffset: 6 + readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0 + readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr) + + readonly property real alignedWidth: Theme.px(modalWidth, dpr) + readonly property real alignedX: Theme.snap(modalX, dpr) + readonly property real alignedY: Theme.snap(modalY, dpr) + + // Extra headroom above the window for the slide-in animation + readonly property real _animHeadroom: 16 + readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr)) + readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad - _animHeadroom, dpr)) + readonly property real contentX: Theme.snap(alignedX - windowX, dpr) + readonly property real contentY: Theme.snap(alignedY - windowY, dpr) + readonly property real windowWidth: alignedWidth + contentX + shadowPad + readonly property real _animatedContentH: Theme.snap(_contentImplicitH, dpr) + readonly property real windowHeight: _animatedContentH + contentY + shadowPad + _animHeadroom + + readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + readonly property real cornerRadius: Theme.cornerRadius + + readonly property color borderColor: { + if (!SettingsData.dankLauncherV2BorderEnabled) + return Theme.outlineMedium; + switch (SettingsData.dankLauncherV2BorderColor) { + case "primary": + return Theme.primary; + case "secondary": + return Theme.secondary; + case "outline": + return Theme.outline; + case "surfaceText": + return Theme.surfaceText; + default: + return Theme.primary; + } + } + readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0 + + signal dialogClosed + + function _ensureContentLoadedAndInitialize(query, mode) { + _pendingQuery = query || ""; + _pendingMode = mode || ""; + _pendingInitialize = true; + contentVisible = true; + contentLoader.active = true; + + if (spotlightContent) { + _initializeContent(_pendingQuery, _pendingMode); + _pendingInitialize = false; + } + } + + function _initializeContent(query, mode) { + if (!spotlightContent) + return; + contentVisible = true; + + const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : ""); + const targetMode = mode || SessionData.launcherLastMode || "all"; + + if (spotlightContent.searchField) { + spotlightContent.searchField.text = targetQuery; + } + if (spotlightContent.controller) { + spotlightContent.controller.reset(); + spotlightContent.controller.searchMode = targetMode; + spotlightContent.controller.historyIndex = -1; + if (targetQuery.length > 0) + spotlightContent.controller.setSearchQuery(targetQuery); + } + if (spotlightContent.resetScroll) { + spotlightContent.resetScroll(); + } + if (spotlightContent.searchField) { + spotlightContent.searchField.forceActiveFocus(); + spotlightContent.searchField.cursorPosition = spotlightContent.searchField.text.length; + } + } + + function _finishShow(query, mode) { + spotlightOpen = true; + isClosing = false; + openedFromOverview = false; + keyboardActive = true; + ModalManager.openModal(modalHandle); + if (useHyprlandFocusGrab) + focusGrab.active = true; + _ensureContentLoadedAndInitialize(query || "", mode || ""); + } + + function _openCommon(query, mode) { + closeCleanupTimer.stop(); + const focusedScreen = CompositorService.getFocusedScreen(); + if (focusedScreen && launcherWindow.screen !== focusedScreen) { + spotlightOpen = false; + isClosing = false; + launcherWindow.screen = focusedScreen; + Qt.callLater(() => root._finishShow(query, mode)); + return; + } + _finishShow(query, mode); + } + + function show() { + _openCommon("", ""); + } + function showWithQuery(query) { + _openCommon(query, ""); + } + function showWithMode(mode) { + _openCommon("", mode); + } + + function hide() { + if (!spotlightOpen) + return; + openedFromOverview = false; + isClosing = true; + contentVisible = false; + keyboardActive = false; + spotlightOpen = false; + focusGrab.active = false; + ModalManager.closeModal(modalHandle); + closeCleanupTimer.start(); + } + + function toggle() { + spotlightOpen ? hide() : show(); + } + + function toggleWithMode(mode) { + spotlightOpen ? hide() : showWithMode(mode); + } + + function toggleWithQuery(query) { + spotlightOpen ? hide() : showWithQuery(query); + } + + Timer { + id: closeCleanupTimer + interval: root._motionDuration + 30 + repeat: false + onTriggered: { + isClosing = false; + dialogClosed(); + } + } + + HyprlandFocusGrab { + id: focusGrab + windows: [launcherWindow] + active: false + onCleared: { + if (spotlightOpen) + hide(); + } + } + + Connections { + target: ModalManager + function onCloseAllModalsExcept(excludedModal) { + if (excludedModal !== modalHandle && spotlightOpen) + hide(); + } + } + + Connections { + target: Quickshell + function onScreensChanged() { + if (Quickshell.screens.length === 0) + return; + const screenName = launcherWindow.screen?.name; + if (screenName) { + for (let i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === screenName) + return; + } + } + if (spotlightOpen) + hide(); + const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0]; + if (newScreen) + launcherWindow.screen = newScreen; + } + } + + // Background click catcher + PanelWindow { + id: clickCatcher + screen: launcherWindow.screen + visible: spotlightOpen || isClosing + color: "transparent" + + WlrLayershell.namespace: "dms:spotlight:clickcatcher" + WlrLayershell.layer: WlrLayershell.Top + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + bottom: true + left: true + right: true + } + + mask: Region { + item: bgMask + + Region { + item: bgHole + intersection: Intersection.Subtract + } + } + + Item { + id: bgMask + visible: false + anchors.fill: parent + } + + Rectangle { + id: bgHole + visible: false + color: "transparent" + x: root.windowX + y: root.windowY + width: root.windowWidth + height: root.windowHeight + } + + MouseArea { + anchors.fill: parent + enabled: spotlightOpen + onClicked: root.hide() + } + } + + // Launcher window + PanelWindow { + id: launcherWindow + visible: spotlightOpen || isClosing + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WindowBlur { + targetWindow: launcherWindow + readonly property real op: Math.max(0, Math.min(1, (modalContainer.opacity - 0.06) * 2)) + blurX: modalContainer.x + blurY: modalContainer.y + modalContainer.slideOffset + blurWidth: contentVisible ? root.alignedWidth * op : 0 + blurHeight: contentVisible ? root._contentImplicitH * op : 0 + blurRadius: root.cornerRadius + } + + WlrLayershell.namespace: "dms:spotlight" + WlrLayershell.layer: { + switch (Quickshell.env("DMS_MODAL_LAYER")) { + case "overlay": + return WlrLayershell.Overlay; + default: + return WlrLayershell.Top; + } + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None + + anchors { + top: true + left: true + } + + WlrLayershell.margins { + left: root.windowX + top: root.windowY + right: 0 + bottom: 0 + } + + implicitWidth: root.windowWidth + implicitHeight: root.windowHeight + + mask: Region { + item: inputMask + } + + Rectangle { + id: inputMask + visible: false + color: "transparent" + x: modalContainer.x + y: modalContainer.y + modalContainer.slideOffset + width: root.alignedWidth + height: root._contentImplicitH + } + + Item { + id: modalContainer + x: root.contentX + y: root.contentY + width: root.alignedWidth + height: root._animatedContentH + visible: _renderActive + + property bool _renderActive: contentVisible + property real slideOffset: contentVisible ? 0 : -root._animHeadroom + + opacity: contentVisible ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: contentVisible ? root._openDuration : root._closeDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: contentVisible ? [0.0, 0.0, 0.2, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0] + onRunningChanged: { + if (!running && !root.contentVisible) + modalContainer._renderActive = false; + } + } + } + + Behavior on slideOffset { + NumberAnimation { + duration: root._motionDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: contentVisible ? [0.2, 0.0, 0.0, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0] + } + } + + Connections { + target: root + function onContentVisibleChanged() { + if (root.contentVisible) + modalContainer._renderActive = true; + } + } + + ElevationShadow { + anchors.fill: contentWrapper + level: root.shadowLevel + fallbackOffset: root.shadowFallbackOffset + targetColor: root.backgroundColor + borderColor: root.borderColor + borderWidth: root.borderWidth + targetRadius: root.cornerRadius + shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + } + + Item { + id: contentWrapper + x: 0 + y: modalContainer.slideOffset + width: parent.width + height: root._animatedContentH + + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = true + } + + FocusScope { + anchors.fill: parent + focus: keyboardActive + + Loader { + id: contentLoader + anchors.fill: parent + active: root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize + asynchronous: false + sourceComponent: SpotlightLauncherContent { + focus: true + parentModal: root + } + + onLoaded: { + if (root._pendingInitialize) { + root._initializeContent(root._pendingQuery, root._pendingMode); + root._pendingInitialize = false; + } + } + } + + Keys.onEscapePressed: event => { + root.hide(); + event.accepted = true; + } + } + } + } + } +} diff --git a/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml b/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml index 5791b0df..79ec335e 100644 --- a/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml +++ b/quickshell/Modals/DankLauncherV2/LauncherContextMenu.qml @@ -13,6 +13,7 @@ Popup { property var controller: null property var searchField: null property var parentHandler: null + property bool allowEditActions: true signal hideRequested signal editAppRequested(var app) @@ -112,12 +113,14 @@ Popup { text: I18n.tr("Hide App"), action: hideCurrentApp }); - items.push({ - type: "item", - icon: "edit", - text: I18n.tr("Edit App"), - action: editCurrentApp - }); + if (allowEditActions) { + items.push({ + type: "item", + icon: "edit", + text: I18n.tr("Edit App"), + action: editCurrentApp + }); + } } if (item?.actions && item.actions.length > 0) { diff --git a/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml new file mode 100644 index 00000000..6261384b --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/SpotlightLauncherContent.qml @@ -0,0 +1,475 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +FocusScope { + id: root + + property var parentModal: null + property alias searchField: searchInput + property alias controller: searchController + + readonly property bool _hasQuery: searchInput.text.length > 0 + readonly property real _searchBarH: 56 + readonly property real _surfaceInset: BlurService.enabled ? (_hasQuery ? Theme.spacingS : Theme.spacingXS) : 0 + readonly property real _searchAreaH: _searchBarH + _surfaceInset * 2 + readonly property real _statusH: 92 + readonly property real _rowH: 64 + readonly property real _maxResultsH: Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55) + readonly property var _resultRows: _buildRows() + readonly property real _resultsContentH: _resultRows.length > 0 ? _resultRows.length * _rowH : _statusH + readonly property real _resultsH: _hasQuery ? Math.min(_resultsContentH, _maxResultsH) : 0 + readonly property int _fastDuration: 90 + readonly property int _resizeDuration: 110 + + implicitHeight: _searchAreaH + (_resultsH > 0 ? 1 + _resultsH : 0) + + function resetScroll() { + resultsList.resetScroll(); + } + + function _buildRows() { + const flat = searchController.flatModel || []; + const sections = searchController.sections || []; + const rows = []; + for (let i = 0; i < flat.length; i++) { + const entry = flat[i]; + if (!entry || entry.isHeader || !entry.item) + continue; + const section = sections[entry.sectionIndex] || null; + rows.push({ + "_rowId": entry.item.id || (entry.sectionId + ":" + entry.indexInSection + ":" + i), + "item": entry.item, + "flatIndex": i, + "sectionTitle": section?.title || "", + "sectionIcon": section?.icon || "" + }); + } + return rows; + } + + function _focusSearch() { + searchInput.forceActiveFocus(); + searchInput.cursorPosition = searchInput.text.length; + } + + function _showContextMenu(item, sceneX, sceneY, fromKeyboard) { + if (!item || !contextMenu.hasContextMenuActions(item)) + return; + const localPos = root.mapFromItem(null, sceneX, sceneY); + contextMenu.show(localPos.x, localPos.y, item, fromKeyboard); + } + + function _handleKey(event) { + const hasCtrl = event.modifiers & Qt.ControlModifier; + const hasAlt = event.modifiers & Qt.AltModifier; + + switch (event.key) { + case Qt.Key_Escape: + if (searchController.clearPluginFilter()) { + event.accepted = true; + return; + } + root.parentModal?.hide(); + event.accepted = true; + return; + case Qt.Key_Backspace: + if (searchInput.text.length === 0) { + if (searchController.clearPluginFilter()) { + event.accepted = true; + return; + } + if (searchController.autoSwitchedToFiles) { + searchController.restorePreviousMode(); + event.accepted = true; + return; + } + } + event.accepted = false; + return; + case Qt.Key_Down: + searchController.selectNext(); + event.accepted = true; + return; + case Qt.Key_Up: + searchController.selectPrevious(); + event.accepted = true; + return; + case Qt.Key_PageDown: + searchController.selectPageDown(7); + event.accepted = true; + return; + case Qt.Key_PageUp: + searchController.selectPageUp(7); + event.accepted = true; + return; + case Qt.Key_J: + if (hasCtrl) { + searchController.selectNext(); + event.accepted = true; + return; + } + break; + case Qt.Key_K: + if (hasCtrl) { + searchController.selectPrevious(); + event.accepted = true; + return; + } + break; + case Qt.Key_Tab: + if (_hasQuery) + _cycleCategory(false); + event.accepted = true; + return; + case Qt.Key_Backtab: + if (_hasQuery) + _cycleCategory(true); + event.accepted = true; + return; + case Qt.Key_Return: + case Qt.Key_Enter: + if (event.modifiers & Qt.ShiftModifier) { + searchController.pasteSelected(); + } else { + searchController.executeSelected(); + } + event.accepted = true; + return; + case Qt.Key_Menu: + case Qt.Key_F10: + if (contextMenu.hasContextMenuActions(searchController.selectedItem)) { + const scenePos = resultsList.getSelectedItemPosition(); + _showContextMenu(searchController.selectedItem, scenePos.x, scenePos.y, true); + event.accepted = true; + return; + } + break; + case Qt.Key_1: + if (hasCtrl || hasAlt) { + searchController.setMode("all"); + event.accepted = true; + return; + } + break; + case Qt.Key_2: + if (hasCtrl || hasAlt) { + searchController.setMode("apps"); + event.accepted = true; + return; + } + break; + case Qt.Key_3: + if (hasCtrl || hasAlt) { + searchController.setMode("files"); + event.accepted = true; + return; + } + break; + case Qt.Key_4: + if (hasCtrl || hasAlt) { + searchController.setMode("plugins"); + event.accepted = true; + return; + } + break; + case Qt.Key_Slash: + if (event.modifiers === Qt.NoModifier && searchInput.text.length === 0) { + searchController.setMode("files", true); + event.accepted = true; + return; + } + break; + } + + event.accepted = false; + } + + Controller { + id: searchController + active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true + viewModeContext: "spotlight" + + onItemExecuted: { + root.parentModal?.hide(); + if (SettingsData.spotlightCloseNiriOverview && NiriService.inOverview) + NiriService.toggleOverview(); + } + } + + LauncherContextMenu { + id: contextMenu + parent: root + controller: searchController + searchField: searchInput + parentHandler: root + allowEditActions: false + } + + Connections { + target: searchController + function onModeChanged(mode) { + if (searchController.autoSwitchedToFiles) + return; + SessionData.setLauncherLastMode(mode); + } + function onSearchQueryRequested(query) { + searchInput.text = query; + root._focusSearch(); + } + } + + Item { + id: searchBarItem + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: root._searchAreaH + + Rectangle { + id: searchBarSurface + anchors.fill: parent + anchors.margins: root._surfaceInset + radius: height / 2 + color: Theme.withAlpha(root._hasQuery ? Theme.surfaceContainerHigh : Theme.surfaceContainer, root._hasQuery ? Theme.popupTransparency : Math.max(0.68, Theme.popupTransparency * 0.9)) + border.color: BlurService.enabled && !root._hasQuery ? Theme.withAlpha(Theme.outline, 0.08) : "transparent" + border.width: BlurService.enabled && !root._hasQuery ? 1 : 0 + + Behavior on color { + ColorAnimation { + duration: root._fastDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + id: leadingWell + width: 36 + height: 36 + radius: height / 2 + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + color: searchInput.activeFocus ? Theme.primaryContainer : Theme.surfaceContainer + + DankIcon { + anchors.centerIn: parent + name: searchController.activePluginId ? "extension" : searchController.searchMode === "files" ? "folder" : "search" + size: 20 + color: searchInput.activeFocus ? Theme.primary : Theme.surfaceVariantText + } + } + + Row { + id: rightControls + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Row { + id: categoryRow + spacing: Theme.spacingXS + visible: root._hasQuery + anchors.verticalCenter: parent.verticalCenter + + Repeater { + model: root._categoryModel + + delegate: Item { + id: categoryChip + required property var modelData + required property int index + + readonly property bool isSelected: root._isCategorySelected(modelData) + + width: chipLabel.implicitWidth + Theme.spacingM * 2 + height: 26 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: categoryChip.isSelected ? Theme.primary : chipArea.containsMouse ? Theme.surfaceHover : Theme.surfaceVariantAlpha + + Behavior on color { + ColorAnimation { + duration: root._fastDuration + easing.type: Theme.standardEasing + } + } + + StyledText { + id: chipLabel + anchors.centerIn: parent + text: categoryChip.modelData.label + font.pixelSize: Theme.fontSizeSmall + font.weight: categoryChip.isSelected ? Font.Medium : Font.Normal + color: categoryChip.isSelected ? Theme.primaryText : Theme.surfaceVariantText + } + } + + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root._selectCategory(categoryChip.index) + } + } + } + } + + DankActionButton { + id: clearButton + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + iconSize: 16 + visible: searchInput.text.length > 0 + onClicked: { + searchInput.text = ""; + searchController.reset(); + root._focusSearch(); + } + } + } + + Text { + anchors.left: leadingWell.right + anchors.leftMargin: Theme.spacingM + anchors.right: rightControls.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Spotlight Search") + font.pixelSize: 18 + font.weight: Font.Medium + color: Theme.outlineButton + visible: searchInput.text.length === 0 + clip: true + } + + TextInput { + id: searchInput + anchors.left: leadingWell.right + anchors.leftMargin: Theme.spacingM + anchors.right: rightControls.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: 18 + font.weight: Font.Medium + color: Theme.surfaceText + selectionColor: Theme.primary + selectedTextColor: Theme.primaryText + clip: true + focus: true + + onTextChanged: { + if (text.length > 0) { + searchController.setSearchQuery(text); + } else { + searchController.reset(); + } + } + + Keys.onPressed: event => root._handleKey(event) + } + } + } + + Rectangle { + anchors.top: searchBarItem.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: root._surfaceInset + anchors.rightMargin: root._surfaceInset + height: 1 + color: Theme.outlineMedium + opacity: root._resultsH > 0 ? 0.55 : 0 + + Behavior on opacity { + NumberAnimation { + duration: root._fastDuration + easing.type: Theme.standardEasing + } + } + } + + Item { + id: resultsContainer + anchors.top: searchBarItem.bottom + anchors.topMargin: 1 + anchors.left: parent.left + anchors.right: parent.right + clip: true + height: root._resultsH + + Behavior on height { + NumberAnimation { + duration: root._resizeDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: [0.2, 0.0, 0.0, 1.0, 1.0, 1.0] + } + } + + SpotlightResultsList { + id: resultsList + anchors.fill: parent + controller: searchController + hasQuery: root._hasQuery + rows: root._resultRows + + onItemRightClicked: (index, item, sceneX, sceneY) => { + root._showContextMenu(item, sceneX, sceneY, false); + } + } + } + + readonly property var _categoryModel: [ + { + "label": I18n.tr("All"), + "mode": "all" + }, + { + "label": I18n.tr("Apps"), + "mode": "apps" + }, + { + "label": I18n.tr("Files"), + "mode": "files" + }, + { + "label": I18n.tr("Plugins"), + "mode": "plugins" + } + ] + + function _isCategorySelected(cat) { + return searchController.searchMode === cat.mode; + } + + function _cycleCategory(reverse) { + let idx = 0; + for (let i = 0; i < _categoryModel.length; i++) { + if (_isCategorySelected(_categoryModel[i])) { + idx = i; + break; + } + } + idx = reverse ? (idx - 1 + _categoryModel.length) % _categoryModel.length : (idx + 1) % _categoryModel.length; + _selectCategory(idx); + } + + function _selectCategory(index) { + const cat = _categoryModel[index]; + if (!cat) + return; + searchController.setMode(cat.mode, false); + if (root._hasQuery) + searchController.setSearchQuery(searchInput.text); + root._focusSearch(); + } +} diff --git a/quickshell/Modals/DankLauncherV2/SpotlightResultRow.qml b/quickshell/Modals/DankLauncherV2/SpotlightResultRow.qml new file mode 100644 index 00000000..e6335229 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/SpotlightResultRow.qml @@ -0,0 +1,299 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import "../../Common/htmlElide.js" as HtmlElide + +Rectangle { + id: root + + property var item: null + property string sectionTitle: "" + property string sectionIcon: "" + property bool isSelected: false + property var controller: null + property int flatIndex: -1 + property bool isHovered: itemArea.containsMouse || quickToggleArea.containsMouse + + signal clicked + signal rightClicked(real mouseX, real mouseY) + + readonly property string iconValue: { + if (!item) + return ""; + switch (item.iconType) { + case "material": + case "nerd": + return "material:" + (item.icon || "apps"); + case "unicode": + return "unicode:" + (item.icon || ""); + case "composite": + return item.iconFull || ""; + case "image": + default: + return item.icon || ""; + } + } + + readonly property string previewSource: { + const data = item?.data; + const raw = data?.imageUrl || data?.imagePath || (data?.path && isImageFile(data.path) ? data.path : ""); + if (!raw) + return ""; + if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("file://")) + return raw; + if (raw.startsWith("/")) + return "file://" + raw; + return raw; + } + readonly property bool hasMediaPreview: previewSource.length > 0 + readonly property bool previewAnimated: previewSource.toLowerCase().indexOf(".gif") >= 0 + + readonly property string typeLabel: { + if (!item) + return ""; + switch (item.type) { + case "plugin_browse": + return I18n.tr("Browse"); + case "plugin": + return I18n.tr("Plugin"); + case "file": + return item.data?.is_dir ? I18n.tr("Folder") : I18n.tr("File"); + default: + return ""; + } + } + + width: parent?.width ?? 200 + height: 64 + radius: Theme.cornerRadius + color: root.isSelected ? Theme.primaryPressed : root.isHovered ? Theme.primaryHoverLight : "transparent" + + Behavior on color { + ColorAnimation { + duration: 90 + easing.type: Theme.standardEasing + } + } + + DankRipple { + id: rippleLayer + rippleColor: Theme.surfaceText + cornerRadius: root.radius + } + + MouseArea { + id: itemArea + z: 2 + anchors.fill: parent + anchors.rightMargin: root.item?.type === "plugin_browse" ? 38 : 0 + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onPositionChanged: { + if (root.controller) + root.controller.keyboardNavigationActive = false; + } + + onPressed: mouse => { + if (mouse.button === Qt.LeftButton) + rippleLayer.trigger(mouse.x, mouse.y); + } + + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + const scenePos = mapToItem(null, mouse.x, mouse.y); + root.rightClicked(scenePos.x, scenePos.y); + } else { + root.clicked(); + } + } + } + + Rectangle { + id: iconWell + width: 40 + height: 40 + radius: Theme.cornerRadius + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + color: root.isSelected ? Theme.primaryContainer : Theme.surfaceContainerHigh + border.color: Theme.withAlpha(root.isSelected ? Theme.primary : Theme.outline, root.isSelected ? 0.28 : 0.12) + border.width: 1 + + AppIconRenderer { + anchors.centerIn: parent + width: 30 + height: 30 + iconValue: root.iconValue + iconSize: 30 + fallbackText: (root.item?.name?.length > 0) ? root.item.name.charAt(0).toUpperCase() : "?" + materialIconSizeAdjustment: 10 + } + } + + Column { + id: textColumn + anchors.left: iconWell.right + anchors.leftMargin: Theme.spacingM + anchors.right: previewFrame.visible ? previewFrame.left : metaRow.left + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + StyledText { + id: nameText + width: parent.width + text: root.item?._hName ?? root.item?.name ?? "" + textFormat: root.item?._hRich ? Text.RichText : Text.PlainText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + maximumLineCount: 1 + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + } + + TextMetrics { + id: subProbe + font.pixelSize: Theme.fontSizeSmall + font.family: Theme.fontFamily + elide: Qt.ElideRight + elideWidth: textColumn.width + text: root.item?._hRich ? HtmlElide.stripHtmlTags(root.item?._hSub ?? "") : "" + } + + readonly property int _richBudget: { + if (!subProbe.text) + return 0; + const elided = subProbe.elidedText; + return elided.endsWith("\u2026") ? elided.length - 1 : elided.length; + } + + StyledText { + width: parent.width + text: root.item?._hRich ? HtmlElide.elideRichText(root.item._hSub ?? "", textColumn._richBudget) : (root.item?.subtitle ?? root.sectionTitle) + textFormat: root.item?._hRich ? Text.RichText : Text.PlainText + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + maximumLineCount: 1 + elide: Text.ElideRight + visible: text.length > 0 + horizontalAlignment: Text.AlignLeft + } + } + + Row { + id: metaRow + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + visible: childrenRect.width > 0 + + Rectangle { + visible: root.typeLabel.length > 0 + width: typeText.implicitWidth + Theme.spacingS * 2 + height: 22 + radius: height / 2 + anchors.verticalCenter: parent.verticalCenter + color: Theme.surfaceVariantAlpha + + StyledText { + id: typeText + anchors.centerIn: parent + text: root.typeLabel + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + } + + Rectangle { + visible: root.item?.type === "plugin_browse" + width: 28 + height: 28 + radius: height / 2 + anchors.verticalCenter: parent.verticalCenter + color: quickToggleArea.containsMouse ? Theme.surfaceHover : "transparent" + + readonly property bool isAllowed: { + if (root.item?.type !== "plugin_browse") + return false; + const pluginId = root.item?.data?.pluginId; + if (!pluginId) + return false; + SettingsData.launcherPluginVisibility; + return SettingsData.getPluginAllowWithoutTrigger(pluginId); + } + + DankIcon { + anchors.centerIn: parent + name: parent.isAllowed ? "visibility" : "visibility_off" + size: 17 + color: parent.isAllowed ? Theme.primary : Theme.surfaceVariantText + } + + MouseArea { + id: quickToggleArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + const pluginId = root.item?.data?.pluginId; + if (!pluginId) + return; + SettingsData.setPluginAllowWithoutTrigger(pluginId, !parent.isAllowed); + } + } + } + + SourceBadge { + visible: root.item?.type === "app" + anchors.verticalCenter: parent.verticalCenter + source: root.item?.type === "app" ? (root.item.source || "") : "" + glyphSize: 14 + } + } + + Rectangle { + id: previewFrame + visible: root.hasMediaPreview + width: 64 + height: 44 + radius: Theme.cornerRadius + anchors.right: metaRow.left + anchors.rightMargin: metaRow.visible ? Theme.spacingS : 0 + anchors.verticalCenter: parent.verticalCenter + clip: true + color: Theme.surfaceContainerHigh + border.color: Theme.withAlpha(Theme.outline, 0.16) + border.width: 1 + + Image { + anchors.fill: parent + source: root.previewSource + asynchronous: true + fillMode: Image.PreserveAspectCrop + visible: !root.previewAnimated + } + + AnimatedImage { + anchors.fill: parent + source: root.previewSource + fillMode: Image.PreserveAspectCrop + playing: visible + visible: root.previewAnimated + } + } + + function isImageFile(path) { + if (!path) + return false; + const ext = path.split(".").pop().toLowerCase(); + return ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "jxl", "avif", "heif", "exr"].indexOf(ext) >= 0; + } +} diff --git a/quickshell/Modals/DankLauncherV2/SpotlightResultsList.qml b/quickshell/Modals/DankLauncherV2/SpotlightResultsList.qml new file mode 100644 index 00000000..1dcf8e71 --- /dev/null +++ b/quickshell/Modals/DankLauncherV2/SpotlightResultsList.qml @@ -0,0 +1,183 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property var controller: null + property bool hasQuery: false + property var rows: [] + + signal itemRightClicked(int index, var item, real mouseX, real mouseY) + + function resetScroll() { + mainListView.contentY = mainListView.originY; + } + + function ensureVisible(flatIndex) { + if (!controller || flatIndex < 0) + return; + for (let i = 0; i < rows.length; i++) { + if ((rows[i]?.flatIndex ?? -1) === flatIndex) { + mainListView.positionViewAtIndex(i, ListView.Contain); + return; + } + } + } + + function getSelectedItemPosition() { + const fallback = mapToItem(null, width / 2, Math.min(height / 2, 56)); + if (!controller || controller.selectedFlatIndex < 0) + return fallback; + for (let i = 0; i < rows.length; i++) { + if ((rows[i]?.flatIndex ?? -1) === controller.selectedFlatIndex) { + const rowY = i * mainListView.rowHeight - mainListView.contentY + mainListView.originY; + return mapToItem(null, width / 2, Math.max(28, Math.min(height - 28, rowY + mainListView.rowHeight / 2))); + } + } + return fallback; + } + + Connections { + target: root.controller + function onSelectedFlatIndexChanged() { + if (root.controller?.keyboardNavigationActive) + Qt.callLater(() => root.ensureVisible(root.controller.selectedFlatIndex)); + } + } + + DankListView { + id: mainListView + anchors.fill: parent + clip: true + visible: root.rows.length > 0 + + readonly property int rowHeight: 64 + + model: ScriptModel { + values: root.rows + objectProp: "_rowId" + } + + add: null + remove: null + displaced: null + move: null + + delegate: Item { + id: delegateRoot + required property var modelData + required property int index + + width: mainListView.width + height: mainListView.rowHeight + + SpotlightResultRow { + anchors.fill: parent + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + anchors.topMargin: 3 + anchors.bottomMargin: 3 + item: delegateRoot.modelData?.item ?? null + sectionTitle: delegateRoot.modelData?.sectionTitle ?? "" + sectionIcon: delegateRoot.modelData?.sectionIcon ?? "" + flatIndex: delegateRoot.modelData?.flatIndex ?? -1 + controller: root.controller + isSelected: (delegateRoot.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex + + onClicked: { + if (root.controller && delegateRoot.modelData?.item) + root.controller.executeItem(delegateRoot.modelData.item); + } + + onRightClicked: (mouseX, mouseY) => { + root.itemRightClicked(delegateRoot.modelData?.flatIndex ?? -1, delegateRoot.modelData?.item ?? null, mouseX, mouseY); + } + } + } + } + + Item { + anchors.fill: parent + visible: root.hasQuery && root.rows.length === 0 + + Row { + anchors.centerIn: parent + spacing: Theme.spacingM + + Rectangle { + width: 40 + height: 40 + radius: Theme.cornerRadius + anchors.verticalCenter: parent.verticalCenter + color: Theme.surfaceContainerHigh + + DankIcon { + anchors.centerIn: parent + name: root.controller?.isSearching || root.controller?.isFileSearching ? "search" : statusIcon() + size: 22 + color: Theme.surfaceVariantText + + function statusIcon() { + const mode = root.controller?.searchMode ?? "all"; + if (mode === "files") + return "folder_open"; + if (mode === "plugins") + return "extension"; + if (mode === "apps") + return "apps"; + return "search_off"; + } + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: Math.min(420, root.width - 88) + spacing: 2 + + StyledText { + width: parent.width + text: statusTitle() + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + + function statusTitle() { + if (root.controller?.isSearching || root.controller?.isFileSearching) + return I18n.tr("Searching"); + if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable) + return I18n.tr("File search unavailable"); + if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2) + return I18n.tr("Keep typing"); + return I18n.tr("No results"); + } + } + + StyledText { + width: parent.width + text: statusSubtitle() + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + maximumLineCount: 2 + wrapMode: Text.WordWrap + elide: Text.ElideRight + + function statusSubtitle() { + if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable) + return I18n.tr("Install dsearch to search files."); + if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2) + return I18n.tr("Type at least 2 characters to search files."); + return I18n.tr("Try a different search or switch filters."); + } + } + } + } + } +} diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 0063f295..a2a04ee1 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -205,7 +205,9 @@ FocusScope { visible: active focus: active - sourceComponent: LauncherTab {} + sourceComponent: LauncherTab { + parentModal: root.parentModal + } onActiveChanged: { if (active && item) diff --git a/quickshell/Modules/Settings/FrameTab.qml b/quickshell/Modules/Settings/FrameTab.qml index 448499f0..a069f4a5 100644 --- a/quickshell/Modules/Settings/FrameTab.qml +++ b/quickshell/Modules/Settings/FrameTab.qml @@ -308,6 +308,15 @@ Item { onToggled: checked => SettingsData.set("frameCloseGaps", !checked) } + SettingsToggleRow { + settingKey: "frameUseSpotlightLauncher" + tags: ["frame", "connected", "launcher", "spotlight", "search", "minimal"] + text: I18n.tr("Use Spotlight Launcher") + description: I18n.tr("Use the centered minimal launcher instead of the connected V2 launcher") + checked: SettingsData.frameUseSpotlightLauncher + onToggled: checked => SettingsData.set("frameUseSpotlightLauncher", checked) + } + SettingsButtonGroupRow { settingKey: "frameLauncherEmergeSide" tags: ["frame", "connected", "launcher", "modal", "emerge", "direction", "bottom", "top"] diff --git a/quickshell/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml index 70bf57bd..af983b53 100644 --- a/quickshell/Modules/Settings/LauncherTab.qml +++ b/quickshell/Modules/Settings/LauncherTab.qml @@ -8,6 +8,8 @@ import qs.Modules.Settings.Widgets Item { id: root + property var parentModal: null + FileBrowserModal { id: logoFileBrowser browserTitle: I18n.tr("Select Launcher Logo") @@ -30,6 +32,43 @@ Item { anchors.horizontalCenter: parent.horizontalCenter spacing: Theme.spacingXL + SettingsCard { + width: parent.width + iconName: "search" + title: I18n.tr("Launcher Style") + settingKey: "launcherStyle" + + SettingsControlledByFrame { + visible: SettingsData.connectedFrameModeActive + parentModal: root.parentModal + settingLabel: I18n.tr("Launcher Style") + reason: I18n.tr("Managed by Frame Mode") + } + + StyledText { + width: parent.width + visible: !SettingsData.connectedFrameModeActive + text: SettingsData.launcherStyle === "spotlight" ? I18n.tr("Minimal Spotlight-style bar: appears instantly at the top of the screen and expands as you type.") : I18n.tr("Full-featured launcher with mode tabs, grid view, and action panel.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + SettingsButtonGroupRow { + visible: !SettingsData.connectedFrameModeActive + settingKey: "launcherStyleSelector" + tags: ["launcher", "style", "spotlight", "full", "minimal"] + text: I18n.tr("Style") + model: [I18n.tr("Full"), I18n.tr("Spotlight")] + currentIndex: SettingsData.launcherStyle === "spotlight" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("launcherStyle", index === 1 ? "spotlight" : "full"); + } + } + } + SettingsCard { width: parent.width iconName: "apps"