Files
2026-07-26 21:16:55 +02:00

289 lines
9.8 KiB
QML

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
}
}
}
}
}