import Quickshell import Quickshell.Wayland import QtQuick import qs.config import qs.components import qs.services // The application menu, implemented as a full-screen layer surface rather than // a PopupWindow. That gives click-outside-to-dismiss and reliable keyboard // focus on any wlroots compositor, without compositor-specific focus grabs. PanelWindow { id: root required property var bar readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" visible: Session.isOpen("launcher", root.screenName) screen: root.bar.screen WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: Config.namespace + "-launcher" // Exclusive so the search field receives keys immediately on open; the // surface is unmapped while closed, so nothing is held hostage. WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive anchors { left: true right: true top: true bottom: true } exclusionMode: ExclusionMode.Ignore color: "transparent" // ---- Data ------------------------------------------------------------ readonly property var categories: ["All", "Development", "Games", "Graphics", "Internet", "Multimedia", "Office", "Science", "System", "Utility", "Other"] property string category: "All" function groupFor(entry: var): string { const c = entry.categories || []; // Ordered: many entries carry several categories, and the first match // wins, so the more specific groups are tested first. if (c.includes("Game")) return "Games"; if (c.includes("Development")) return "Development"; if (c.includes("Graphics")) return "Graphics"; if (c.includes("Network")) return "Internet"; if (c.includes("AudioVideo") || c.includes("Audio") || c.includes("Video")) return "Multimedia"; if (c.includes("Office")) return "Office"; if (c.includes("Science") || c.includes("Education")) return "Science"; if (c.includes("Settings") || c.includes("System")) return "System"; if (c.includes("Utility")) return "Utility"; return "Other"; } function score(entry: var, needle: string): int { const name = (entry.name || "").toLowerCase(); if (name === needle) return 100; if (name.startsWith(needle)) return 80; if (name.includes(needle)) return 60; if ((entry.genericName || "").toLowerCase().includes(needle)) return 40; if ((entry.keywords || []).some(k => k.toLowerCase().includes(needle))) return 30; if ((entry.comment || "").toLowerCase().includes(needle)) return 20; if ((entry.id || "").toLowerCase().includes(needle)) return 10; return 0; } readonly property var allApps: [...DesktopEntries.applications.values].filter(e => !e.noDisplay) readonly property var results: { const needle = search.text.trim().toLowerCase(); if (needle === "") { return root.allApps.filter(e => root.category === "All" || root.groupFor(e) === root.category).sort((a, b) => (a.name || "").localeCompare(b.name || "")); } // While searching, ignore the category selection — searching the whole // menu is what people expect. return root.allApps.map(e => ({ entry: e, score: root.score(e, needle) })).filter(r => r.score > 0).sort((a, b) => b.score - a.score || (a.entry.name || "").localeCompare(b.entry.name || "")).map(r => r.entry); } function launch(entry: var): void { Session.close(); AppLauncher.launch(entry); } onVisibleChanged: { if (root.visible) { search.text = ""; root.category = "All"; list.currentIndex = 0; search.forceActiveFocus(); } } // ---- Chrome ---------------------------------------------------------- // Click-away scrim. MouseArea { anchors.fill: parent onClicked: Session.close() } Rectangle { id: menu anchors { left: parent.left bottom: parent.bottom leftMargin: Theme.padding bottomMargin: Theme.barHeight + Theme.padding } // Never taller or wider than the screen, so a rotated or small output // still shows a usable menu. width: Math.min(Config.launcherWidth, root.width - Theme.padding * 2) height: Math.min(Config.launcherHeight, root.height - Theme.barHeight - Theme.padding * 2) color: Theme.surface radius: Theme.radius * 2 border.width: Theme.borderWidth border.color: Theme.border // Swallow clicks so they do not reach the scrim behind. MouseArea { anchors.fill: parent } Keys.onEscapePressed: Session.close() Column { anchors.fill: parent anchors.margins: Theme.padding * 2 spacing: Theme.padding * 2 // Search field Rectangle { width: parent.width height: 34 radius: Theme.radius color: Theme.background border.width: Theme.borderWidth border.color: search.activeFocus ? Theme.accent : Theme.border TextInput { id: search anchors.fill: parent anchors.leftMargin: Theme.padding * 2 anchors.rightMargin: Theme.padding * 2 verticalAlignment: TextInput.AlignVCenter color: Theme.text font.family: Theme.fontFamily font.pixelSize: Theme.fontSize selectByMouse: true selectionColor: Theme.accent focus: true Keys.onEscapePressed: Session.close() Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1) Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0) Keys.onReturnPressed: list.activateCurrent() Keys.onEnterPressed: list.activateCurrent() Text { anchors.fill: parent verticalAlignment: Text.AlignVCenter visible: search.text === "" text: "Search applications…" color: Theme.textDim font: search.font } } } // Category selector. Wraps rather than scrolls so every category // stays reachable at any launcher width. Flow { id: categoryFlow width: parent.width spacing: Theme.spacing Repeater { model: root.categories Rectangle { id: chip required property string modelData readonly property bool selected: root.category === chip.modelData implicitWidth: categoryLabel.implicitWidth + Theme.padding * 3 implicitHeight: 24 radius: Theme.radius color: chip.selected ? Theme.accent : categoryMouse.containsMouse ? Theme.surfaceHover : Theme.background Text { id: categoryLabel anchors.centerIn: parent text: chip.modelData color: chip.selected ? Theme.background : Theme.textDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } MouseArea { id: categoryMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { root.category = chip.modelData; list.currentIndex = 0; } } } } } // Results ListView { id: list width: parent.width height: parent.height - y clip: true spacing: 1 boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: false model: root.results function activateCurrent(): void { const entry = root.results[list.currentIndex]; if (entry) root.launch(entry); } highlightMoveDuration: Theme.animDuration delegate: AppEntry { required property var modelData required property int index entry: modelData width: list.width selected: list.currentIndex === index onActivated: root.launch(modelData) onEntered: list.currentIndex = index } Text { anchors.centerIn: parent visible: list.count === 0 text: "No matching applications" color: Theme.textDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } } } } }