289 lines
9.8 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|