initial commit
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user