import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Wayland PanelWindow { id: root visible: LauncherService.visible color: "transparent" anchors { top: true bottom: true left: true right: true } exclusiveZone: 0 WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive WlrLayershell.layer: WlrLayer.Overlay readonly property int panelWidth: Config.panelWidth readonly property int panelHeight: Config.panelHeight // Hyprland-only nicety: close on workspace switch + focus-grab dismissal. // Loaded lazily so the import only resolves on Hyprland. Loader { active: Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE").length > 0 source: "HyprlandIntegration.qml" onLoaded: { item.window = root; item.armed = Qt.binding(() => root.visible); } } MouseArea { anchors.fill: parent onClicked: LauncherService.close() } readonly property bool searching: LauncherService.query.length > 0 readonly property bool gifMode: LauncherService.gifMode readonly property bool masonryActive: gifMode && LauncherService.filtered.length > 0 && LauncherService.filtered[0]._isGif === true readonly property int columns: 6 readonly property int cellSize: 100 // Hover-selection is disarmed whenever the query changes — so the list // rebuilding under a stationary cursor doesn't yank the keyboard selection // off the top result. Re-armed once the cursor actually moves. property bool hoverArmed: true Connections { target: LauncherService function onQueryChanged() { root.hoverArmed = false } } onVisibleChanged: { if (visible) { queryField.text = ""; LauncherService.selectedIndex = 0; hoverArmed = false; Qt.callLater(() => queryField.forceActiveFocus()); } } HoverHandler { onPointChanged: root.hoverArmed = true } Rectangle { anchors.centerIn: parent width: root.panelWidth height: root.panelHeight color: Theme.opaque_background radius: Theme.radius border.color: Theme.border_subtle border.width: 1 MouseArea { anchors.fill: parent acceptedButtons: Qt.AllButtons onPressed: mouse => mouse.accepted = true } ColumnLayout { anchors.fill: parent anchors.margins: Theme.padding * 3 spacing: Theme.spacing * 2 Rectangle { Layout.fillWidth: true implicitHeight: queryField.implicitHeight + Theme.padding * 2 color: Theme.overlay_weak radius: Theme.radius / 2 RowLayout { anchors.fill: parent anchors.leftMargin: Theme.padding * 2 anchors.rightMargin: Theme.padding * 2 spacing: Theme.spacing Text { text: "" color: Theme.idle font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } TextField { id: queryField Layout.fillWidth: true placeholderText: "Search applications… = calc . unicode ! gif" color: Theme.foreground placeholderTextColor: Theme.idle font.family: Theme.fontFamily font.pixelSize: Theme.fontSize background: Item {} selectByMouse: true onTextChanged: { LauncherService.query = text; LauncherService.selectedIndex = 0; } Keys.onPressed: event => { const list = LauncherService.filtered; const cols = root.searching ? 1 : root.columns; let idx = LauncherService.selectedIndex; if (event.key === Qt.Key_Escape) { LauncherService.close(); event.accepted = true; } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { LauncherService.launchSelected(); event.accepted = true; } else if (event.key === Qt.Key_Down) { idx = Math.min(list.length - 1, idx + cols); LauncherService.selectedIndex = idx; event.accepted = true; } else if (event.key === Qt.Key_Up) { idx = Math.max(0, idx - cols); LauncherService.selectedIndex = idx; event.accepted = true; } else if (event.key === Qt.Key_Right && (!root.searching || root.masonryActive)) { LauncherService.selectedIndex = Math.min(list.length - 1, idx + 1); event.accepted = true; } else if (event.key === Qt.Key_Left && (!root.searching || root.masonryActive)) { LauncherService.selectedIndex = Math.max(0, idx - 1); event.accepted = true; } } } } } // Grid view (empty query) ---------------------------------------- GridView { id: gridView visible: !root.searching Layout.fillWidth: true Layout.fillHeight: true clip: true cellWidth: width / root.columns cellHeight: root.cellSize model: visible ? LauncherService.filtered : null currentIndex: LauncherService.selectedIndex onCurrentIndexChanged: positionViewAtIndex(currentIndex, GridView.Contain) delegate: Item { id: gridCell required property int index required property var modelData width: GridView.view.cellWidth height: GridView.view.cellHeight Rectangle { anchors.fill: parent anchors.margins: 4 radius: Theme.radius / 2 color: (LauncherService.selectedIndex === gridCell.index) ? Theme.overlay_strong : (hover.containsMouse ? Theme.overlay_weak : "transparent") border.color: (LauncherService.selectedIndex === gridCell.index) ? Theme.border_subtle : "transparent" border.width: 1 ColumnLayout { anchors.fill: parent anchors.margins: Theme.padding spacing: 2 Item { Layout.alignment: Qt.AlignHCenter Layout.preferredWidth: 48 Layout.preferredHeight: 48 IconOrFallback { anchors.fill: parent entry: gridCell.modelData } } Text { Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter text: gridCell.modelData ? gridCell.modelData.name : "" color: Theme.foreground font.family: Theme.fontFamily font.pixelSize: Theme.fontSize * 0.75 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight maximumLineCount: 2 wrapMode: Text.Wrap } } MouseArea { id: hover anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: LauncherService.launch(gridCell.modelData) onPositionChanged: if (root.hoverArmed) LauncherService.selectedIndex = gridCell.index } } } } // List view (with query) ----------------------------------------- ListView { id: listView visible: root.searching && !root.masonryActive Layout.fillWidth: true Layout.fillHeight: true clip: true spacing: 2 model: visible ? LauncherService.filtered : null currentIndex: LauncherService.selectedIndex onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) delegate: Rectangle { required property int index required property var modelData width: ListView.view.width height: 48 radius: Theme.radius / 2 color: (LauncherService.selectedIndex === index) ? Theme.overlay_strong : (rowHover.containsMouse ? Theme.overlay_weak : "transparent") RowLayout { anchors.fill: parent anchors.leftMargin: Theme.padding * 2 anchors.rightMargin: Theme.padding * 2 spacing: Theme.spacing * 2 Item { Layout.preferredWidth: modelData && modelData._noIcon ? 0 : 32 Layout.preferredHeight: 32 visible: !(modelData && modelData._noIcon) IconOrFallback { anchors.fill: parent entry: modelData } } Text { visible: !!(modelData && modelData._glyph) Layout.preferredWidth: 36 Layout.alignment: Qt.AlignVCenter text: modelData && modelData._glyph ? modelData._glyph : "" color: Theme.foreground font.family: "Noto Color Emoji" font.pixelSize: Theme.fontSize * 1.4 horizontalAlignment: Text.AlignHCenter } ColumnLayout { Layout.fillWidth: true spacing: 0 Text { Layout.fillWidth: true text: modelData ? modelData.name : "" color: Theme.foreground font.family: Theme.fontFamily font.pixelSize: Theme.fontSize elide: Text.ElideRight maximumLineCount: 1 } Text { Layout.fillWidth: true visible: text.length > 0 text: modelData ? (modelData.genericName || modelData.comment || "") : "" color: Theme.idle font.family: Theme.fontFamily font.pixelSize: Theme.fontSize * 0.7 elide: Text.ElideRight maximumLineCount: 1 } } } MouseArea { id: rowHover anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: LauncherService.launch(modelData) onPositionChanged: if (root.hoverArmed) LauncherService.selectedIndex = index } } } // Masonry view (gif results) ------------------------------------- Flickable { id: masonry visible: root.masonryActive Layout.fillWidth: true Layout.fillHeight: true clip: true contentWidth: width contentHeight: masonryRow.implicitHeight boundsBehavior: Flickable.StopAtBounds RowLayout { id: masonryRow width: masonry.width spacing: Theme.spacing Repeater { model: 3 ColumnLayout { required property int index Layout.fillWidth: true Layout.alignment: Qt.AlignTop spacing: Theme.spacing Repeater { model: LauncherService.gifColumns[index] || [] delegate: Rectangle { required property var modelData Layout.fillWidth: true Layout.preferredHeight: width * (modelData._ratio || 1) radius: Theme.radius / 2 color: "transparent" border.width: (LauncherService.selectedIndex === modelData._globalIndex) ? 2 : 0 border.color: Theme.accent AnimatedImage { anchors.fill: parent anchors.margins: 2 source: modelData._previewUrl || "" fillMode: Image.PreserveAspectCrop asynchronous: true cache: true smooth: true } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: LauncherService.launch(modelData) onPositionChanged: if (root.hoverArmed) LauncherService.selectedIndex = modelData._globalIndex } } } } } } } Text { Layout.alignment: Qt.AlignHCenter visible: LauncherService.filtered.length === 0 text: root.gifMode ? "No GIFs match" : "No applications match" color: Theme.idle font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } } } }