477 lines
16 KiB
QML
477 lines
16 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Io
|
|
import Quickshell.Hyprland
|
|
|
|
import "../config" as Cfg
|
|
import "../components"
|
|
|
|
PanelWindow {
|
|
id: root
|
|
|
|
property bool isTop: true
|
|
readonly property int barEdgePad: Cfg.Config.frameVariant === "bar" ? (isTop ? Cfg.Config.frameBarPadTop : Cfg.Config.frameBarPadBottom) : 0
|
|
property int barHeight: Cfg.Config.barHeight + barEdgePad
|
|
property int barMargin: Cfg.Config.margin
|
|
|
|
readonly property var t: Cfg.Config.theme
|
|
|
|
readonly property int launcherGap: 12
|
|
|
|
GlobalShortcut {
|
|
id: launcherShortcut
|
|
name: "toggle"
|
|
onPressed: root.toggle()
|
|
}
|
|
|
|
property bool _isOpen: false
|
|
readonly property bool isOpen: _isOpen
|
|
property bool _keyboardNav: false
|
|
property var allItems: []
|
|
property bool loading: false
|
|
property int lastCacheUpdate: 0
|
|
property var favoriteIds: []
|
|
readonly property string favoritesPath: Quickshell.env("HOME") + "/.config/quickshell/launcher/favorites.txt"
|
|
|
|
|
|
readonly property int defaultIndex: Cfg.Config.launcherSelectFirst ? 0 : -1
|
|
|
|
readonly property var filteredItems: {
|
|
const q = searchInput.text.toLowerCase()
|
|
let filtered
|
|
if (!q) {
|
|
filtered = allItems.slice(0, 80)
|
|
} else {
|
|
filtered = allItems.filter(i => i.name.toLowerCase().startsWith(q))
|
|
}
|
|
const favs = filtered.filter(i => root.favoriteIds.includes(i.id))
|
|
const rest = filtered.filter(i => !root.favoriteIds.includes(i.id))
|
|
const result = []
|
|
if (favs.length > 0)
|
|
result.push(...favs.map(i => Object.assign({}, i, { section: "Favorites" })))
|
|
if (rest.length > 0)
|
|
result.push(...rest.map(i => Object.assign({}, i, { section: "All Applications" })))
|
|
return result
|
|
}
|
|
|
|
function toggle() {
|
|
if (!_isOpen) {
|
|
var fm = Hyprland.focusedMonitor
|
|
if (!fm || fm.name !== screen.name) return
|
|
}
|
|
_isOpen ? close() : open()
|
|
}
|
|
|
|
function open() {
|
|
var fm = Hyprland.focusedMonitor
|
|
if (!fm || fm.name !== screen.name) return
|
|
closeTimer.stop()
|
|
visible = true
|
|
_isOpen = true
|
|
searchInput.text = ""
|
|
resultsList.currentIndex = root.defaultIndex
|
|
loadItems()
|
|
loadFavorites()
|
|
Qt.callLater(() => searchInput.forceActiveFocus())
|
|
}
|
|
|
|
function close() {
|
|
_isOpen = false
|
|
closeTimer.restart()
|
|
}
|
|
|
|
Connections {
|
|
target: root
|
|
function onActiveChanged() {
|
|
if (!root.active && root._isOpen) root.close()
|
|
}
|
|
}
|
|
|
|
function loadItems() {
|
|
loading = true
|
|
var now = Date.now()
|
|
var staleMs = 300000
|
|
if (now - lastCacheUpdate < staleMs) {
|
|
readCache()
|
|
return
|
|
}
|
|
readCache()
|
|
lastCacheUpdate = now
|
|
updateProc.command = [Cfg.Config.launcherScript, "--update-only"]
|
|
updateProc.running = true
|
|
}
|
|
|
|
function readCache() {
|
|
const path = Cfg.Config.launcherCacheDir + "/drun.txt"
|
|
dataProc.rawOutput = ""
|
|
dataProc.command = ["cat", path]
|
|
dataProc.running = true
|
|
}
|
|
|
|
function shellQuote(str) {
|
|
return "'" + str.replace(/'/g, "'\\''") + "'"
|
|
}
|
|
|
|
function parseExec(str) {
|
|
const args = []
|
|
let current = ""
|
|
let inSingle = false
|
|
let inDouble = false
|
|
|
|
for (let i = 0; i < str.length; i++) {
|
|
const ch = str[i]
|
|
|
|
if (inSingle) {
|
|
if (ch === "'") inSingle = false
|
|
else current += ch
|
|
} else if (inDouble) {
|
|
if (ch === '"') inDouble = false
|
|
else current += ch
|
|
} else if (ch === "'") {
|
|
inSingle = true
|
|
} else if (ch === '"') {
|
|
inDouble = true
|
|
} else if (/\s/.test(ch)) {
|
|
if (current.length > 0) {
|
|
args.push(current)
|
|
current = ""
|
|
}
|
|
} else {
|
|
current += ch
|
|
}
|
|
}
|
|
if (current.length > 0) args.push(current)
|
|
return args
|
|
}
|
|
|
|
function loadFavorites() {
|
|
favoritesReadProc.command = ["sh", "-c", "cat " + shellQuote(favoritesPath) + " 2>/dev/null || true"]
|
|
favoritesReadProc.running = true
|
|
}
|
|
|
|
function saveFavorites() {
|
|
if (favoriteIds.length === 0) {
|
|
favoritesWriteProc.command = ["sh", "-c", ": > " + shellQuote(favoritesPath)]
|
|
} else {
|
|
const quoted = favoriteIds.map(id => shellQuote(id)).join(" ")
|
|
favoritesWriteProc.command = ["sh", "-c",
|
|
"printf '%s\\n' " + quoted + " > " + shellQuote(favoritesPath)]
|
|
}
|
|
favoritesWriteProc.running = true
|
|
}
|
|
|
|
function toggleFavorite(itemId) {
|
|
const arr = favoriteIds.slice()
|
|
const idx = arr.indexOf(itemId)
|
|
if (idx >= 0) {
|
|
arr.splice(idx, 1)
|
|
} else {
|
|
arr.push(itemId)
|
|
}
|
|
favoriteIds = arr
|
|
saveFavorites()
|
|
}
|
|
|
|
function launch(item) {
|
|
if (!item || !item.exec) return
|
|
|
|
let cleanExec = item.exec.replace(/%[fFuUik]/g, "").trim()
|
|
const logPath = "/tmp/qml_launch.log"
|
|
|
|
const args = parseExec(cleanExec)
|
|
const cmd = args.map(a => shellQuote(a)).join(" ") + " > " + shellQuote(logPath) + " 2>&1 < /dev/null &"
|
|
|
|
launchProc.command = ["sh", "-c", cmd]
|
|
launchProc.running = true
|
|
|
|
console.log("Launching: " + cleanExec)
|
|
close()
|
|
}
|
|
|
|
visible: false
|
|
color: "transparent"
|
|
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
WlrLayershell.layer: WlrLayer.Overlay
|
|
WlrLayershell.namespace: "main-shell-launcher"
|
|
WlrLayershell.exclusiveZone: -1
|
|
WlrLayershell.keyboardFocus: _isOpen ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
|
|
mask: Region { item: root._isOpen ? fullMask : clipContainer }
|
|
|
|
|
|
Item { id: fullMask; anchors.fill: parent }
|
|
|
|
PopupHideTimer {
|
|
id: closeTimer
|
|
onTriggered: {
|
|
root.visible = false
|
|
root.allItems = []
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: updateProc
|
|
onRunningChanged: if (!running) root.readCache()
|
|
}
|
|
|
|
Process {
|
|
id: dataProc
|
|
property string rawOutput: ""
|
|
stdout: SplitParser { onRead: data => dataProc.rawOutput += data + "\n" }
|
|
onRunningChanged: {
|
|
if (!running && rawOutput) {
|
|
const lines = rawOutput.trim().split("\n")
|
|
const items = []
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const p = lines[i].split("\t")
|
|
if (p.length >= 3) items.push({ name: p[0], exec: p[1], id: p[2], icon: p[3] || "" })
|
|
}
|
|
root.allItems = items
|
|
root.loading = false
|
|
}
|
|
}
|
|
}
|
|
|
|
Process { id: launchProc }
|
|
|
|
Process {
|
|
id: favoritesReadProc
|
|
property string rawData: ""
|
|
stdout: SplitParser { onRead: data => favoritesReadProc.rawData += data + "\n" }
|
|
onRunningChanged: {
|
|
if (!running) {
|
|
if (rawData.trim()) {
|
|
root.favoriteIds = rawData.trim().split("\n").filter(l => l.length > 0)
|
|
}
|
|
rawData = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Process { id: favoritesWriteProc }
|
|
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: root._isOpen
|
|
onClicked: root.close()
|
|
}
|
|
|
|
Item {
|
|
id: clipContainer
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
width: 460
|
|
|
|
anchors.top: root.isTop ? parent.top : undefined
|
|
anchors.bottom: root.isTop ? undefined : parent.bottom
|
|
anchors.topMargin: root.isTop ? (root.barHeight + root.launcherGap) : 0
|
|
anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + root.launcherGap)
|
|
height: parent.height - root.barHeight - root.launcherGap
|
|
|
|
clip: true
|
|
|
|
PopupCard {
|
|
id: bubble
|
|
popupOpen: root._isOpen
|
|
isTop: root.isTop
|
|
animEnabled: root.visible
|
|
property int targetHeight: Math.min(layout.implicitHeight + 28, 540)
|
|
height: targetHeight
|
|
Behavior on height {
|
|
enabled: root._isOpen
|
|
NumberAnimation { duration: 154; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
radius: Cfg.Config.popupRadius
|
|
color: t.launcherBg
|
|
border.color: t.launcherBorder
|
|
border.width: Cfg.Config.popupBorderWidth
|
|
|
|
focus: root._isOpen
|
|
Keys.onEscapePressed: root.close()
|
|
|
|
ColumnLayout {
|
|
id: layout
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 14
|
|
spacing: 10
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 44
|
|
radius: 10
|
|
color: t.launcherInput
|
|
border.color: searchInput.activeFocus ? t.launcherInputBorderFocus : t.launcherBorder
|
|
border.width: Cfg.Config.popupBorderWidth
|
|
|
|
Behavior on color { NumberAnimation { duration: 180 } }
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 8
|
|
spacing: 10
|
|
|
|
TextInput {
|
|
id: searchInput
|
|
Layout.fillWidth: true
|
|
color: t.launcherText
|
|
font.pixelSize: 15
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
selectionColor: t.launcherAccent
|
|
|
|
onTextChanged: resultsList.currentIndex = root.defaultIndex
|
|
|
|
Keys.onReturnPressed: {
|
|
if (root.filteredItems.length > 0) {
|
|
root.launch(root.filteredItems[Math.max(0, resultsList.currentIndex)])
|
|
}
|
|
}
|
|
|
|
Keys.onDownPressed: {
|
|
root._keyboardNav = true
|
|
if (resultsList.currentIndex < 0) {
|
|
resultsList.currentIndex = 0;
|
|
} else {
|
|
resultsList.currentIndex = Math.min(resultsList.currentIndex + 1, resultsList.count - 1)
|
|
}
|
|
resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain)
|
|
}
|
|
|
|
Keys.onUpPressed: {
|
|
root._keyboardNav = true
|
|
if (resultsList.currentIndex < 0) {
|
|
resultsList.currentIndex = resultsList.count - 1;
|
|
} else {
|
|
resultsList.currentIndex = Math.max(resultsList.currentIndex - 1, 0)
|
|
}
|
|
resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: resultsList
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: listHeight
|
|
|
|
property int listHeight: Math.min(contentHeight, 400)
|
|
Behavior on listHeight {
|
|
NumberAnimation { duration: 154; easing.type: Easing.OutCubic }
|
|
}
|
|
|
|
clip: true
|
|
model: root.filteredItems
|
|
currentIndex: root.defaultIndex
|
|
spacing: 2
|
|
|
|
section.property: "section"
|
|
section.criteria: ViewSection.FullString
|
|
section.delegate: Rectangle {
|
|
width: resultsList.width
|
|
height: 28
|
|
color: "transparent"
|
|
Text {
|
|
anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
|
|
text: section
|
|
color: t.launcherDim
|
|
font.pixelSize: 11
|
|
font.weight: Font.Bold
|
|
}
|
|
}
|
|
|
|
onModelChanged: currentIndex = root.defaultIndex
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
add: Transition {
|
|
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 100; easing.type: Easing.InQuad }
|
|
}
|
|
|
|
delegate: Rectangle {
|
|
width: resultsList.width
|
|
height: 38
|
|
radius: 8
|
|
property bool hovered: false
|
|
|
|
color: hovered ? t.launcherHover
|
|
: (resultsList.currentIndex === index && index !== -1
|
|
? t.launcherSelected : "transparent")
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
spacing: 12
|
|
|
|
Item {
|
|
width: 22; height: 22
|
|
Layout.alignment: Qt.AlignVCenter
|
|
|
|
Image {
|
|
id: appIcon
|
|
anchors.fill: parent
|
|
source: modelData.icon ? "image://icon/" + modelData.icon : ""
|
|
sourceSize: Qt.size(64, 64)
|
|
fillMode: Image.PreserveAspectFit
|
|
smooth: true
|
|
mipmap: Cfg.Config.launcherMipmapIcons
|
|
visible: status === Image.Ready
|
|
}
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
visible: !appIcon.visible
|
|
text: ""
|
|
color: resultsList.currentIndex === index && index !== -1 ? t.launcherAccent : t.launcherDim
|
|
font.pixelSize: 16
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: modelData.name
|
|
color: t.launcherText
|
|
font.pixelSize: 14
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Item {
|
|
width: 22; height: 22
|
|
Layout.alignment: Qt.AlignVCenter
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.favoriteIds.includes(modelData.id) ? "★" : "☆"
|
|
color: root.favoriteIds.includes(modelData.id) ? t.launcherAccent : t.launcherDim
|
|
font.pixelSize: 14
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onEntered: parent.hovered = true
|
|
onExited: parent.hovered = false
|
|
onClicked: mouse => {
|
|
if (mouse.x >= width - 46) {
|
|
root.toggleFavorite(modelData.id)
|
|
} else {
|
|
root.launch(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|