Files
quickdots/launcher/Launcher.qml
2026-04-24 02:14:35 +00:00

394 lines
15 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
PanelWindow {
id: root
property bool isTop: true
property int barHeight: Cfg.Config.barHeight
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 string mode: "drun"
property bool _keyboardNav: false
property var allItems: []
property bool loading: false
property bool proxyMode: false
readonly property var filteredItems: {
const q = searchInput.text.toLowerCase()
if (!q) return allItems.slice(0, 80)
return allItems.filter(i => i.name.toLowerCase().includes(q))
}
function toggle() { _isOpen ? close() : open() }
function open() {
closeTimer.stop()
visible = true
_isOpen = true
searchInput.text = ""
resultsList.currentIndex = -1
loadItems()
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
readCache()
updateProc.command = [Cfg.Config.launcherScript, "--update-only"]
updateProc.running = true
}
function readCache() {
const path = Cfg.Config.launcherCacheDir + "/" + (mode === "drun" ? "drun.txt" : "run.txt")
dataProc.rawOutput = ""
dataProc.command = ["bash", "-c", "cat " + path]
dataProc.running = true
}
function launch(item) {
if (!item || !item.exec) return
const cleanExec = item.exec.replace(/%[fFuUik]/g, "").trim()
const finalExec = root.proxyMode ? "proxychains4 " + cleanExec : cleanExec
const logPath = "/tmp/qml_launch.log"
launchProc.command = ["sh", "-c", finalExec + " > " + logPath + " 2>&1 &"]
launchProc.running = true
console.log("Launching: " + finalExec)
if (typeof close === "function") close()
}
onModeChanged: if (_isOpen) loadItems()
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.OnDemand : WlrKeyboardFocus.None
mask: Region { item: clipContainer }
Timer {
id: closeTimer
interval: Cfg.Config.popupAnimDuration + 70
onTriggered: {
root.visible = false
root.mode = "drun"
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 }
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
Rectangle {
id: bubble
width: parent.width
property int targetHeight: Math.min(layout.implicitHeight + 28, 540)
height: targetHeight
anchors.top: root.isTop ? parent.top : undefined
anchors.bottom: root.isTop ? undefined : parent.bottom
transform: Translate {
y: root._isOpen ? 0 : (root.isTop ? -bubble.height : bubble.height)
Behavior on y {
NumberAnimation {
duration: Cfg.Config.popupAnimDuration;
easing.type: Easing.OutExpo
}
enabled: root.visible
}
}
opacity: root._isOpen ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
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.fill: parent
anchors.margins: 14
spacing: 10
Rectangle {
Layout.fillWidth: true
height: 44
radius: 10
color: t.launcherInput
border.color: searchInput.activeFocus ? t.launcherInputBorderFocus : t.launcherBorder
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 = -1
Keys.onReturnPressed: {
if (root.filteredItems.length > 0) {
let idx = resultsList.currentIndex;
if (idx === -1) idx = 0;
root.launch(root.filteredItems[idx]);
}
}
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)
}
Keys.onTabPressed: root.mode = root.mode === "drun" ? "run" : "drun"
}
Rectangle {
height: 28
width: modeRow.implicitWidth + 10
radius: 8
color: t.launcherPill
border.color: t.launcherBorder
RowLayout {
id: modeRow
anchors.centerIn: parent
spacing: 2
Repeater {
model: ["drun", "run"]
delegate: Rectangle {
height: 22
width: label.implicitWidth + 14
radius: 6
color: root.mode === modelData ? t.launcherModeActiveBg : "transparent"
Text {
id: label
anchors.centerIn: parent
text: modelData
font.pixelSize: 10
font.bold: root.mode === modelData
color: root.mode === modelData ? t.launcherModeActiveText : t.launcherDim
}
MouseArea {
anchors.fill: parent
onClicked: root.mode = modelData
}
}
}
}
}
Rectangle {
id: proxyBtn
height: 28
width: 110
radius: 8
color: root.proxyMode ? Qt.rgba(1, 0.35, 0.35, 0.22) : t.launcherPill
border.color: root.proxyMode ? Qt.rgba(1, 0.35, 0.35, 0.65) : t.launcherBorder
border.width: 1
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
RowLayout {
id: proxyBtnRow
anchors.centerIn: parent
spacing: 5
Text {
text: "󰒃"
font.pixelSize: 11
color: root.proxyMode ? Qt.rgba(1, 0.45, 0.45, 1) : t.launcherDim
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
text: "proxychains4"
font.pixelSize: 10
font.bold: root.proxyMode
color: root.proxyMode ? Qt.rgba(1, 0.45, 0.45, 1) : t.launcherDim
Behavior on color { ColorAnimation { duration: 150 } }
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.proxyMode = !root.proxyMode
}
}
}
}
ListView {
id: resultsList
Layout.fillWidth: true
implicitHeight: Math.min(contentHeight, 400)
clip: true
model: root.filteredItems
currentIndex: -1
spacing: 2
onModelChanged: currentIndex = -1
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
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 : ""
fillMode: Image.PreserveAspectFit
smooth: true
visible: status === Image.Ready
}
Text {
anchors.centerIn: parent
visible: !appIcon.visible
text: root.mode === "drun" ? "󰣆" : "󰆍"
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
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: parent.hovered = true
onExited: parent.hovered = false
onClicked: root.launch(modelData)
}
}
}
}
}
}
}