import QtQuick import QtQuick.Layouts import QtQuick.Controls import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import Quickshell.Io import "../config" as Cfg import "../components" PanelWindow { id: root property bool isTop: true readonly property int barEdgePad: Cfg.Config.frameVariant === "bar" ? (isTop ? Cfg.Config.frameBarPadTop : Cfg.Config.frameBarPadBottom) : 0 property int barHeight: Cfg.Config.barHeight + barEdgePad property int barMargin: Cfg.Config.margin readonly property var t: Cfg.Config.theme readonly property int launcherGap: 12 property var allItems: [] property var favoriteIds: [] property var accessCounts: ({}) property bool _loaded: false readonly property string _statePath: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher_state.json" function buildItems() { const apps = DesktopEntries.applications.values const items = [] for (let i = 0; i < apps.length; i++) { const e = apps[i] items.push({ name: e.name, exec: e.execString, id: e.id, icon: e.icon, entry: e, actions: e.actions }) } items.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) root.allItems = items } Component.onCompleted: buildItems() Connections { target: DesktopEntries function onApplicationsChanged() { root.buildItems() } } FileView { id: stateFile path: root._statePath printErrors: false watchChanges: true onFileChanged: reload() onAdapterUpdated: writeAdapter() adapter: JsonAdapter { id: stateAdapter property list favorites: [] property var access: ({}) } onLoaded: { root.favoriteIds = stateAdapter.favorites root.accessCounts = stateAdapter.access root._loaded = true } onLoadFailed: { root._loaded = true } } function toggleFavorite(itemId) { const arr = root.favoriteIds.slice() const idx = arr.indexOf(itemId) if (idx >= 0) arr.splice(idx, 1) else arr.push(itemId) root.favoriteIds = arr stateAdapter.favorites = root.favoriteIds } function incrementAccess(itemId) { const counts = Object.assign({}, root.accessCounts) counts[itemId] = (counts[itemId] || 0) + 1 root.accessCounts = counts stateAdapter.access = root.accessCounts } GlobalShortcut { id: launcherShortcut name: "toggle" onPressed: root.toggle() } property bool _isOpen: false readonly property bool isOpen: _isOpen property bool _keyboardNav: false property string _expandingActions: "" property string _savedExpandId: "" property real _savedContentY: 0 property bool _pendingRestore: false property bool _inRestore: false property string expandedItemId: "" readonly property int defaultIndex: Cfg.Config.launcherSelectFirst ? 0 : -1 readonly property var filteredItems: { const q = searchInput.text.toLowerCase() let filtered if (!q) { filtered = allItems.slice(0, 80) } else { filtered = allItems.filter(i => i.name.toLowerCase().startsWith(q)) } const favs = filtered.filter(i => root.favoriteIds.includes(i.id)) let rest = filtered.filter(i => !root.favoriteIds.includes(i.id)) rest.sort((a, b) => (root.accessCounts[b.id] || 0) - (root.accessCounts[a.id] || 0)) const result = [] function pushItem(item) { result.push(Object.assign({}, item, { isHeader: false, isAction: false })) if (root.expandedItemId && item.id === root.expandedItemId && item.actions && item.actions.length > 0) { for (let j = 0; j < item.actions.length; j++) { const a = item.actions[j] result.push({ isHeader: false, isAction: true, name: a.name, id: item.id + ":" + a.id, icon: a.icon, actionObj: a }) } } } if (favs.length > 0) { result.push({ isHeader: true, section: "Favorites" }) for (let i = 0; i < favs.length; i++) pushItem(favs[i]) } if (rest.length > 0) { result.push({ isHeader: true, section: "All Applications" }) for (let i = 0; i < rest.length; i++) pushItem(rest[i]) } return result } function skipHeader(idx, dir) { while (idx >= 0 && idx < root.filteredItems.length) { const item = root.filteredItems[idx] if (item && !item.isHeader) return idx idx += dir } return -1 } function toggle() { if (!_isOpen) { var fm = Hyprland.focusedMonitor if (!fm || fm.name !== screen.name) return } _isOpen ? close() : open() } function open() { var fm = Hyprland.focusedMonitor if (!fm || fm.name !== screen.name) return closeTimer.stop() visible = true _isOpen = true expandedItemId = "" searchInput.text = "" resultsList.currentIndex = root.defaultIndex Qt.callLater(() => resultsList.positionViewAtBeginning()) Qt.callLater(() => searchInput.forceActiveFocus()) } function close() { _isOpen = false expandedItemId = "" closeTimer.restart() } Connections { target: root function onActiveChanged() { if (!root.active && root._isOpen) root.close() } } function launch(item) { if (!item || !item.entry) return item.entry.execute() console.log("Launching: " + item.name) root.incrementAccess(item.id) close() } function executeAction(item) { if (!item || !item.actionObj) return item.actionObj.execute() console.log("Executing action: " + item.name) close() } visible: false color: "transparent" anchors { top: true; bottom: true; left: true; right: true } WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "main-shell-launcher" WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: _isOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None mask: Region { item: root._isOpen ? fullMask : clipContainer } Item { id: fullMask; anchors.fill: parent } PopupHideTimer { id: closeTimer onTriggered: root.visible = false } MouseArea { anchors.fill: parent enabled: root._isOpen onClicked: root.close() } Item { id: clipContainer anchors.horizontalCenter: parent.horizontalCenter width: 460 anchors.top: root.isTop ? parent.top : undefined anchors.bottom: root.isTop ? undefined : parent.bottom anchors.topMargin: root.isTop ? (root.barHeight + root.launcherGap) : 0 anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + root.launcherGap) height: parent.height - root.barHeight - root.launcherGap clip: true PopupCard { id: bubble popupOpen: root._isOpen isTop: root.isTop animEnabled: root.visible property int targetHeight: Math.min(layout.implicitHeight + 28, 540) height: targetHeight Behavior on height { enabled: root._isOpen NumberAnimation { duration: 80; easing.type: Easing.OutCubic } } radius: Cfg.Config.popupRadius color: t.launcherBg border.color: t.launcherBorder border.width: Cfg.Config.popupBorderWidth focus: root._isOpen Keys.onEscapePressed: root.close() ColumnLayout { id: layout anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: 14 spacing: 10 Rectangle { Layout.fillWidth: true height: 44 radius: 10 color: t.launcherInput border.color: searchInput.activeFocus ? t.launcherInputBorderFocus : t.launcherBorder border.width: Cfg.Config.popupBorderWidth Behavior on color { NumberAnimation { duration: 180 } } RowLayout { anchors.fill: parent anchors.leftMargin: 12 anchors.rightMargin: 8 spacing: 10 TextInput { id: searchInput Layout.fillWidth: true color: t.launcherText font.pixelSize: 15 verticalAlignment: TextInput.AlignVCenter selectionColor: t.launcherAccent onTextChanged: { resultsList.currentIndex = root.defaultIndex root.expandedItemId = "" } Keys.onReturnPressed: { const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1) if (idx < 0) return const item = root.filteredItems[idx] if (item.isAction) root.executeAction(item) else root.launch(item) } Keys.onTabPressed: (event) => { event.accepted = true root._keyboardNav = true const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1) if (idx < 0) return const item = root.filteredItems[idx] if (!item || item.isHeader || item.isAction) return root._savedContentY = resultsList.contentY root._savedExpandId = item.id root._expandingActions = item.id if (root.expandedItemId === item.id) { root._pendingRestore = true root.expandedItemId = "" } else if (item.actions && item.actions.length > 0) { root._pendingRestore = true root.expandedItemId = item.id } Qt.callLater(() => { for (let i = 0; i < resultsList.count; i++) { const it = root.filteredItems[i] if (it.id === item.id && !it.isAction && !it.isHeader) { resultsList.currentIndex = i resultsList.positionViewAtIndex(i, ListView.Contain) break } } }) } Keys.onDownPressed: { root._keyboardNav = true var next = resultsList.currentIndex < 0 ? 0 : resultsList.currentIndex + 1 if (next >= resultsList.count) next = resultsList.count - 1 next = root.skipHeader(next, 1) if (next < 0) next = root.skipHeader(0, 1) if (next >= 0) { resultsList.currentIndex = next resultsList.positionViewAtIndex(next, ListView.Contain) } } Keys.onUpPressed: { root._keyboardNav = true var next = resultsList.currentIndex < 0 ? resultsList.count - 1 : resultsList.currentIndex - 1 if (next < 0) next = 0 next = root.skipHeader(next, -1) if (next < 0) next = root.skipHeader(resultsList.count - 1, -1) if (next >= 0) { resultsList.currentIndex = next resultsList.positionViewAtIndex(next, ListView.Contain) } } } } } ListView { id: resultsList Layout.fillWidth: true Layout.preferredHeight: listHeight // Avoid reacting to contentHeight changes from lazy delegate // instantiation during scroll — only animate on model count changes. property int listHeight: 0 NumberAnimation { id: listHeightAnim target: resultsList property: "listHeight" duration: 80 easing.type: Easing.OutCubic } function _syncListHeight() { var h = Math.min(contentHeight, 400) if (h === listHeight) return listHeightAnim.stop() listHeightAnim.from = listHeight listHeightAnim.to = h listHeightAnim.start() } onCountChanged: Qt.callLater(_syncListHeight) Component.onCompleted: Qt.callLater(_syncListHeight) clip: true model: root.filteredItems currentIndex: root.defaultIndex spacing: 2 snapMode: ListView.NoSnap highlightRangeMode: ListView.NoHighlightRange cacheBuffer: 400 reuseItems: true pixelAligned: false boundsBehavior: Flickable.StopAtBounds flickableDirection: Flickable.VerticalFlick onModelChanged: { if (root._expandingActions !== "") { root._expandingActions = "" if (root._savedExpandId !== "") { Qt.callLater(() => { for (let i = 0; i < resultsList.count; i++) { const it = root.filteredItems[i] if (it.id === root._savedExpandId && !it.isAction && !it.isHeader) { resultsList.currentIndex = i resultsList.positionViewAtIndex(i, ListView.Contain) break } } root._pendingRestore = false root._savedExpandId = "" }) } else { root._pendingRestore = false } } else { currentIndex = root.defaultIndex for (let i = 0; i < count; i++) { const md = root.filteredItems[i] if (md && !md.isHeader && !md.isAction) { currentIndex = i break } } Qt.callLater(() => positionViewAtBeginning()) } } ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Connections { target: resultsList function onContentYChanged() { if (root._pendingRestore && !root._inRestore && Math.abs(resultsList.contentY - root._savedContentY) > 1) { root._inRestore = true resultsList.contentY = root._savedContentY root._inRestore = false } } } delegate: Rectangle { id: itemDelegate width: resultsList.width height: 38 radius: modelData.isHeader ? 0 : 8 color: modelData.isHeader ? "transparent" : (starHovered || hovered) ? t.launcherHover : (resultsList.currentIndex === index && index !== -1 ? t.launcherSelected : "transparent") clip: modelData.isAction property bool hovered: false property bool starHovered: false Text { visible: modelData.isHeader anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter } text: modelData.section || "" color: t.launcherDim font.pixelSize: 13 font.weight: Font.Bold } RowLayout { visible: !modelData.isHeader anchors.fill: parent anchors.leftMargin: modelData.isAction ? 28 : 12 anchors.rightMargin: 12 spacing: 12 Item { visible: !modelData.isAction width: 22; height: 22 Layout.alignment: Qt.AlignVCenter Image { id: appIcon anchors.fill: parent source: modelData.icon ? "image://icon/" + modelData.icon : "" sourceSize: Qt.size(64, 64) fillMode: Image.PreserveAspectFit smooth: true mipmap: Cfg.Config.launcherMipmapIcons visible: status === Image.Ready } Text { anchors.centerIn: parent visible: !appIcon.visible text: "󰣆" color: resultsList.currentIndex === index && index !== -1 ? t.launcherAccent : t.launcherDim font.pixelSize: 16 } } Text { visible: modelData.isAction text: "›" color: t.launcherDim font.pixelSize: 14 Layout.alignment: Qt.AlignVCenter } Text { text: modelData.name color: modelData.isAction ? t.launcherDim : t.launcherText font.pixelSize: modelData.isAction ? 13 : 14 Layout.fillWidth: true elide: Text.ElideRight } Text { visible: !modelData.isAction && modelData.actions && modelData.actions.length > 0 text: root.expandedItemId === modelData.id ? "▲" : "▼" color: t.launcherDim font.pixelSize: 9 Layout.alignment: Qt.AlignVCenter } Item { visible: !modelData.isAction width: 26; height: 26 Layout.alignment: Qt.AlignVCenter Text { id: starText anchors.centerIn: parent text: root.favoriteIds.includes(modelData.id) ? "★" : "☆" font.pixelSize: 18 readonly property bool isFav: root.favoriteIds.includes(modelData.id) color: starHovered ? t.launcherStarHover : (isFav ? t.launcherAccent : t.launcherStar) Behavior on color { ColorAnimation { duration: 180; easing.type: Easing.OutCubic } } } } } MouseArea { acceptedButtons: Qt.LeftButton | Qt.RightButton anchors.fill: parent hoverEnabled: true onEntered: parent.hovered = true onExited: { parent.hovered = false; parent.starHovered = false } onPositionChanged: mouse => { parent.starHovered = !modelData.isHeader && !modelData.isAction && (mouse.x >= width - 46) } onClicked: mouse => { if (modelData.isHeader) return if (mouse.button === Qt.RightButton) { if (!modelData.isAction && modelData.actions && modelData.actions.length > 0) { root._savedContentY = resultsList.contentY root._savedExpandId = modelData.id root._expandingActions = modelData.id root._pendingRestore = true if (root.expandedItemId === modelData.id) root.expandedItemId = "" else root.expandedItemId = modelData.id } return } if (modelData.isAction) { root.executeAction(modelData) return } if (mouse.x >= width - 46) root.toggleFavorite(modelData.id) else root.launch(modelData) } } } } } } } }