pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Widgets import "Util.js" as Util FocusScope { id: root required property Theme theme required property Launcher launcherService required property SurfaceManager surfaces property var sessionState: ({ "query": "", "selectedId": "", "scrollY": 0 }) readonly property string query: String(sessionState.query || "") property bool stateReady: false readonly property var panelState: launcherService ? Util.parseJson(launcherService.search(query), { "results": [], "selectedIndex": -1, "providerIntent": "app", "query": "" }) : ({ "results": [], "selectedIndex": -1, "providerIntent": "app", "query": "" }) readonly property var results: panelState.results || [] readonly property int selectedIndex: selectionIndex() readonly property var selectedRow: results.length > 0 ? results[selectedIndex] : ({}) readonly property var selectedActions: selectedRow.actions || [] readonly property var selectedDetails: selectedRow.details || [] readonly property int rowHeight: 52 readonly property int rowSpacing: 4 readonly property int rowStride: rowHeight + rowSpacing readonly property int rightWidth: Math.min(300, Math.max(260, Math.round(width * 0.36))) function updateState(values) { Object.assign(sessionState, values); // The owner may supply a plain JS object rather than a QtObject. sessionStateChanged(); } function selectionIndex() { const index = results.findIndex(row => String(row.id) === String(sessionState.selectedId)); return index >= 0 ? index : (results.length > 0 ? 0 : -1); } function reconcileSelection() { const id = selectedIndex >= 0 ? String(results[selectedIndex].id) : ""; if (sessionState.selectedId !== id) updateState({ "selectedId": id }); if (stateReady) ensureSelectedVisible(); } function setQuery(text) { updateState({ "query": String(text || "") }); } function rememberScroll() { if (stateReady && sessionState.scrollY !== resultsFlick.contentY) updateState({ "scrollY": resultsFlick.contentY }); } function ensureSelectedVisible() { if (selectedIndex < 0) return; const top = selectedIndex * rowStride; const bottom = top + rowHeight; if (top < resultsFlick.contentY) updateState({ "scrollY": top }); else if (bottom > resultsFlick.contentY + resultsFlick.height) updateState({ "scrollY": bottom - resultsFlick.height }); } function revealAction(item) { const top = item.mapToItem(detailContent, 0, 0).y; if (top < detailFlick.contentY) detailFlick.contentY = top; else if (top + item.height > detailFlick.contentY + detailFlick.height) detailFlick.contentY = top + item.height - detailFlick.height; } function close() { if (surfaces) surfaces.closePopout("launcher-activated"); } function activateSelected() { if (!launcherService || results.length === 0) return; launcherService.activate(JSON.stringify(selectedRow)); close(); } function activateAction(index) { if (!launcherService || results.length === 0 || index < 0 || index >= selectedActions.length) return; launcherService.activateAction(JSON.stringify(selectedRow), index); close(); } function select(index) { if (results.length === 0) return; const i = Math.max(0, Math.min(results.length - 1, Number(index))); updateState({ "selectedId": String(results[i].id) }); ensureSelectedVisible(); } function keyAction(event) { if (event.modifiers & Qt.ControlModifier) { const n = Number(event.text); if (n >= 1 && n <= selectedActions.length) { event.accepted = true; if (!event.isAutoRepeat) activateAction(n - 1); } } } width: parent ? parent.width : 0 height: parent ? parent.height : 0 focus: true onResultsChanged: selectionTimer.restart() Timer { id: selectionTimer interval: 0 running: true onTriggered: { root.reconcileSelection(); if (!root.stateReady) { root.stateReady = true; searchInput.input.forceActiveFocus(); } } } Keys.onUpPressed: event => { event.accepted = true; select(selectedIndex - 1); } Keys.onDownPressed: event => { event.accepted = true; select(selectedIndex + 1); } Keys.onReturnPressed: event => { event.accepted = true; if (!event.isAutoRepeat) activateSelected(); } Keys.onEnterPressed: event => { event.accepted = true; if (!event.isAutoRepeat) activateSelected(); } Keys.onPressed: event => keyAction(event) Shortcut { sequences: ["Return", "Enter"] enabled: searchInput.input.activeFocus autoRepeat: false onActivated: root.activateSelected() } StableListModel { id: resultRows rows: root.results } StableListModel { id: actionRows rows: root.selectedActions } Row { anchors.fill: parent anchors.margins: 1 Item { width: Math.max(1, parent.width - root.rightWidth) height: parent.height PopupTextField { id: searchInput theme: root.theme anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 height: 38 icon: "" placeholderText: "search apps" text: root.query onEdited: text => root.setQuery(text) Keys.onUpPressed: event => { event.accepted = true; root.select(root.selectedIndex - 1); } Keys.onDownPressed: event => { event.accepted = true; root.select(root.selectedIndex + 1); } Keys.onPressed: event => root.keyAction(event) } Flickable { id: resultsFlick objectName: "launcherResultList" anchors.left: parent.left anchors.right: parent.right anchors.top: searchInput.bottom anchors.bottom: parent.bottom anchors.margins: 8 contentHeight: resultColumn.implicitHeight contentY: Math.max(0, Math.min(Number(root.sessionState.scrollY || 0), contentHeight - height)) onContentYChanged: root.rememberScroll() clip: true boundsBehavior: Flickable.StopAtBounds Column { id: resultColumn width: resultsFlick.width spacing: root.rowSpacing Repeater { model: resultRows delegate: PopupCard { id: rrow required property int index required property var rowData readonly property var modelData: rowData readonly property bool isSelected: index === root.selectedIndex theme: root.theme padding: 0 active: isSelected hoverable: true externallyPressed: rrowArea.pressed activeFocusOnTab: isSelected Accessible.role: Accessible.ListItem Accessible.name: String(modelData.title || modelData.id || "app") Accessible.selected: isSelected onActiveFocusChanged: if (activeFocus) root.select(index) width: parent.width height: root.rowHeight Item { id: iconSlot anchors.left: parent.left anchors.leftMargin: 11 anchors.verticalCenter: parent.verticalCenter width: 30 height: 30 Rectangle { anchors.fill: parent radius: 8 color: root.theme ? root.theme.content.backdrop : "#121212" } IconImage { id: appIcon anchors.fill: parent anchors.margins: 4 visible: appIcon.status === Image.Ready source: String(rrow.modelData.icon || "").length > 0 ? Quickshell.iconPath(String(rrow.modelData.icon), true) : "" } Text { anchors.centerIn: parent visible: !appIcon.visible text: String(rrow.modelData.glyph || "") font.family: "Symbols Nerd Font Mono" font.pixelSize: 17 color: root.theme ? root.theme.ui.accent : "#d86dd8" } } Column { anchors.left: iconSlot.right anchors.leftMargin: 12 anchors.right: ordinal.left anchors.rightMargin: 10 anchors.verticalCenter: parent.verticalCenter spacing: 2 NuguText { theme: root.theme selected: rrow.isSelected text: rrow.modelData.title || rrow.modelData.id || "app" width: parent.width } NuguText { theme: root.theme variant: "meta" selected: rrow.isSelected text: rrow.modelData.subtitle || rrow.modelData.comment || rrow.modelData.desktopId || "app" width: parent.width opacity: rrow.isSelected ? 0.82 : 0.72 } } NuguText { id: ordinal anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter theme: root.theme variant: "mono" selected: rrow.isSelected text: rrow.isSelected ? "enter" : "" width: 42 horizontalAlignment: Text.AlignRight opacity: rrow.isSelected ? 0.8 : 0.52 } MouseArea { id: rrowArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { root.select(rrow.index); rrow.forceActiveFocus(); } onDoubleClicked: root.activateSelected() } } } } } } Item { width: root.rightWidth height: parent.height PopupDivider { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom theme: root.theme } Flickable { id: detailFlick objectName: "launcherDetails" anchors.fill: parent anchors.margins: 22 contentHeight: detailContent.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds Column { id: detailContent width: detailFlick.width spacing: 6 Row { width: parent.width height: 58 spacing: 14 Item { width: 56 height: 56 Rectangle { anchors.fill: parent radius: 15 color: root.theme ? root.theme.content.backdrop : "#121212" } IconImage { id: detailIcon anchors.fill: parent anchors.margins: 8 visible: detailIcon.status === Image.Ready source: String(root.selectedRow.icon || "").length > 0 ? Quickshell.iconPath(String(root.selectedRow.icon), true) : "" } Text { anchors.centerIn: parent visible: !detailIcon.visible text: String(root.selectedRow.glyph || "") font.family: "Symbols Nerd Font Mono" font.pixelSize: 27 color: root.theme ? root.theme.ui.accent : "#d86dd8" } } Column { width: Math.max(1, parent.width - 70) anchors.verticalCenter: parent.verticalCenter spacing: 3 NuguText { theme: root.theme variant: "title" text: root.selectedRow.title || "no apps" width: parent.width font.pixelSize: 22 } NuguText { theme: root.theme variant: "mono" text: root.selectedRow.genericName || root.selectedRow.desktopId || "app" width: parent.width opacity: 0.62 } } } NuguText { theme: root.theme variant: "mono" text: "actions" width: parent.width topPadding: 24 bottomPadding: 8 opacity: 0.68 } PopupButton { id: openButton theme: root.theme width: parent.width height: 42 text: "open" role: "primary" enabled: root.results.length > 0 && !!root.launcherService onActiveFocusChanged: if (activeFocus) root.revealAction(openButton) onClicked: root.activateSelected() } Repeater { model: actionRows delegate: PopupButton { id: actionRow required property int index required property var rowData readonly property var modelData: rowData theme: root.theme width: parent.width height: 42 text: modelData.title || modelData.id || "action" onActiveFocusChanged: if (activeFocus) root.revealAction(actionRow) onClicked: root.activateAction(index) } } NuguText { theme: root.theme variant: "mono" text: "desktop entry" width: parent.width topPadding: 24 bottomPadding: 8 opacity: 0.68 } PopupCard { theme: root.theme padding: 0 width: parent.width height: detailColumn.implicitHeight + 20 Column { id: detailColumn anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: 10 spacing: 6 Repeater { model: root.selectedDetails delegate: Row { id: detailRow required property var modelData width: parent.width spacing: 10 NuguText { theme: root.theme variant: "meta" text: detailRow.modelData.label || "" width: 70 opacity: 0.62 } NuguText { theme: root.theme variant: "meta" text: detailRow.modelData.value || "-" width: Math.max(1, parent.width - 80) horizontalAlignment: Text.AlignRight } } } } } } } } } }