607 lines
24 KiB
QML
607 lines
24 KiB
QML
import QtQuick
|
||
import QtQuick.Layouts
|
||
import QtQuick.Controls
|
||
import Quickshell
|
||
import Quickshell.Wayland
|
||
import Quickshell.Hyprland
|
||
import Quickshell.Io
|
||
|
||
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
|
||
|
||
property var allItems: []
|
||
property var favoriteIds: []
|
||
property var accessCounts: ({})
|
||
property bool _loaded: false
|
||
|
||
readonly property string _statePath: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher_state.json"
|
||
|
||
function buildItems() {
|
||
const apps = DesktopEntries.applications.values
|
||
const items = []
|
||
for (let i = 0; i < apps.length; i++) {
|
||
const e = apps[i]
|
||
items.push({
|
||
name: e.name,
|
||
exec: e.execString,
|
||
id: e.id,
|
||
icon: e.icon,
|
||
entry: e,
|
||
actions: e.actions
|
||
})
|
||
}
|
||
items.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||
root.allItems = items
|
||
}
|
||
|
||
Component.onCompleted: buildItems()
|
||
|
||
Connections {
|
||
target: DesktopEntries
|
||
function onApplicationsChanged() { root.buildItems() }
|
||
}
|
||
|
||
FileView {
|
||
id: stateFile
|
||
path: root._statePath
|
||
printErrors: false
|
||
watchChanges: true
|
||
onFileChanged: reload()
|
||
onAdapterUpdated: writeAdapter()
|
||
adapter: JsonAdapter {
|
||
id: stateAdapter
|
||
property list<string> favorites: []
|
||
property var access: ({})
|
||
}
|
||
onLoaded: {
|
||
root.favoriteIds = stateAdapter.favorites
|
||
root.accessCounts = stateAdapter.access
|
||
root._loaded = true
|
||
}
|
||
onLoadFailed: {
|
||
root._loaded = true
|
||
}
|
||
}
|
||
|
||
function toggleFavorite(itemId) {
|
||
const arr = root.favoriteIds.slice()
|
||
const idx = arr.indexOf(itemId)
|
||
if (idx >= 0)
|
||
arr.splice(idx, 1)
|
||
else
|
||
arr.push(itemId)
|
||
root.favoriteIds = arr
|
||
stateAdapter.favorites = root.favoriteIds
|
||
}
|
||
|
||
function incrementAccess(itemId) {
|
||
const counts = Object.assign({}, root.accessCounts)
|
||
counts[itemId] = (counts[itemId] || 0) + 1
|
||
root.accessCounts = counts
|
||
stateAdapter.access = root.accessCounts
|
||
}
|
||
|
||
GlobalShortcut {
|
||
id: launcherShortcut
|
||
name: "toggle"
|
||
onPressed: root.toggle()
|
||
}
|
||
|
||
property bool _isOpen: false
|
||
readonly property bool isOpen: _isOpen
|
||
property bool _keyboardNav: false
|
||
property string _expandingActions: ""
|
||
property string _savedExpandId: ""
|
||
property real _savedContentY: 0
|
||
|
||
property bool _pendingRestore: false
|
||
property bool _inRestore: false
|
||
|
||
property string expandedItemId: ""
|
||
|
||
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))
|
||
let rest = filtered.filter(i => !root.favoriteIds.includes(i.id))
|
||
rest.sort((a, b) => (root.accessCounts[b.id] || 0) - (root.accessCounts[a.id] || 0))
|
||
const result = []
|
||
|
||
function pushItem(item) {
|
||
result.push(Object.assign({}, item, { isHeader: false, isAction: false }))
|
||
if (root.expandedItemId && item.id === root.expandedItemId && item.actions && item.actions.length > 0) {
|
||
for (let j = 0; j < item.actions.length; j++) {
|
||
const a = item.actions[j]
|
||
result.push({
|
||
isHeader: false,
|
||
isAction: true,
|
||
name: a.name,
|
||
id: item.id + ":" + a.id,
|
||
icon: a.icon,
|
||
actionObj: a
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
if (favs.length > 0) {
|
||
result.push({ isHeader: true, section: "Favorites" })
|
||
for (let i = 0; i < favs.length; i++)
|
||
pushItem(favs[i])
|
||
}
|
||
if (rest.length > 0) {
|
||
result.push({ isHeader: true, section: "All Applications" })
|
||
for (let i = 0; i < rest.length; i++)
|
||
pushItem(rest[i])
|
||
}
|
||
return result
|
||
}
|
||
|
||
function skipHeader(idx, dir) {
|
||
while (idx >= 0 && idx < root.filteredItems.length) {
|
||
const item = root.filteredItems[idx]
|
||
if (item && !item.isHeader) return idx
|
||
idx += dir
|
||
}
|
||
return -1
|
||
}
|
||
|
||
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
|
||
expandedItemId = ""
|
||
searchInput.text = ""
|
||
resultsList.currentIndex = root.defaultIndex
|
||
Qt.callLater(() => resultsList.positionViewAtBeginning())
|
||
Qt.callLater(() => searchInput.forceActiveFocus())
|
||
}
|
||
|
||
function close() {
|
||
_isOpen = false
|
||
expandedItemId = ""
|
||
closeTimer.restart()
|
||
}
|
||
|
||
Connections {
|
||
target: root
|
||
function onActiveChanged() {
|
||
if (!root.active && root._isOpen) root.close()
|
||
}
|
||
}
|
||
|
||
function launch(item) {
|
||
if (!item || !item.entry) return
|
||
item.entry.execute()
|
||
console.log("Launching: " + item.name)
|
||
root.incrementAccess(item.id)
|
||
close()
|
||
}
|
||
|
||
function executeAction(item) {
|
||
if (!item || !item.actionObj) return
|
||
item.actionObj.execute()
|
||
console.log("Executing action: " + item.name)
|
||
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
|
||
}
|
||
|
||
|
||
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: 80; 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
|
||
root.expandedItemId = ""
|
||
}
|
||
|
||
Keys.onReturnPressed: {
|
||
const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1)
|
||
if (idx < 0) return
|
||
const item = root.filteredItems[idx]
|
||
if (item.isAction)
|
||
root.executeAction(item)
|
||
else
|
||
root.launch(item)
|
||
}
|
||
|
||
Keys.onTabPressed: (event) => {
|
||
event.accepted = true
|
||
root._keyboardNav = true
|
||
const idx = root.skipHeader(Math.max(0, resultsList.currentIndex), 1)
|
||
if (idx < 0) return
|
||
const item = root.filteredItems[idx]
|
||
if (!item || item.isHeader || item.isAction) return
|
||
root._savedContentY = resultsList.contentY
|
||
root._savedExpandId = item.id
|
||
root._expandingActions = item.id
|
||
if (root.expandedItemId === item.id) {
|
||
root._pendingRestore = true
|
||
root.expandedItemId = ""
|
||
} else if (item.actions && item.actions.length > 0) {
|
||
root._pendingRestore = true
|
||
root.expandedItemId = item.id
|
||
}
|
||
Qt.callLater(() => {
|
||
for (let i = 0; i < resultsList.count; i++) {
|
||
const it = root.filteredItems[i]
|
||
if (it.id === item.id && !it.isAction && !it.isHeader) {
|
||
resultsList.currentIndex = i
|
||
resultsList.positionViewAtIndex(i, ListView.Contain)
|
||
break
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
Keys.onDownPressed: {
|
||
root._keyboardNav = true
|
||
var next = resultsList.currentIndex < 0 ? 0 : resultsList.currentIndex + 1
|
||
if (next >= resultsList.count) next = resultsList.count - 1
|
||
next = root.skipHeader(next, 1)
|
||
if (next < 0) next = root.skipHeader(0, 1)
|
||
if (next >= 0) {
|
||
resultsList.currentIndex = next
|
||
resultsList.positionViewAtIndex(next, ListView.Contain)
|
||
}
|
||
}
|
||
|
||
Keys.onUpPressed: {
|
||
root._keyboardNav = true
|
||
var next = resultsList.currentIndex < 0 ? resultsList.count - 1 : resultsList.currentIndex - 1
|
||
if (next < 0) next = 0
|
||
next = root.skipHeader(next, -1)
|
||
if (next < 0) next = root.skipHeader(resultsList.count - 1, -1)
|
||
if (next >= 0) {
|
||
resultsList.currentIndex = next
|
||
resultsList.positionViewAtIndex(next, ListView.Contain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ListView {
|
||
id: resultsList
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: listHeight
|
||
|
||
// Avoid reacting to contentHeight changes from lazy delegate
|
||
// instantiation during scroll — only animate on model count changes.
|
||
property int listHeight: 0
|
||
|
||
NumberAnimation {
|
||
id: listHeightAnim
|
||
target: resultsList
|
||
property: "listHeight"
|
||
duration: 80
|
||
easing.type: Easing.OutCubic
|
||
}
|
||
|
||
function _syncListHeight() {
|
||
var h = Math.min(contentHeight, 400)
|
||
if (h === listHeight) return
|
||
listHeightAnim.stop()
|
||
listHeightAnim.from = listHeight
|
||
listHeightAnim.to = h
|
||
listHeightAnim.start()
|
||
}
|
||
|
||
onCountChanged: Qt.callLater(_syncListHeight)
|
||
Component.onCompleted: Qt.callLater(_syncListHeight)
|
||
|
||
clip: true
|
||
model: root.filteredItems
|
||
currentIndex: root.defaultIndex
|
||
spacing: 2
|
||
snapMode: ListView.NoSnap
|
||
highlightRangeMode: ListView.NoHighlightRange
|
||
cacheBuffer: 400
|
||
reuseItems: true
|
||
pixelAligned: false
|
||
boundsBehavior: Flickable.StopAtBounds
|
||
flickableDirection: Flickable.VerticalFlick
|
||
|
||
onModelChanged: {
|
||
if (root._expandingActions !== "") {
|
||
root._expandingActions = ""
|
||
if (root._savedExpandId !== "") {
|
||
Qt.callLater(() => {
|
||
for (let i = 0; i < resultsList.count; i++) {
|
||
const it = root.filteredItems[i]
|
||
if (it.id === root._savedExpandId && !it.isAction && !it.isHeader) {
|
||
resultsList.currentIndex = i
|
||
resultsList.positionViewAtIndex(i, ListView.Contain)
|
||
break
|
||
}
|
||
}
|
||
root._pendingRestore = false
|
||
root._savedExpandId = ""
|
||
})
|
||
} else {
|
||
root._pendingRestore = false
|
||
}
|
||
} else {
|
||
currentIndex = root.defaultIndex
|
||
for (let i = 0; i < count; i++) {
|
||
const md = root.filteredItems[i]
|
||
if (md && !md.isHeader && !md.isAction) {
|
||
currentIndex = i
|
||
break
|
||
}
|
||
}
|
||
Qt.callLater(() => positionViewAtBeginning())
|
||
}
|
||
}
|
||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||
|
||
Connections {
|
||
target: resultsList
|
||
function onContentYChanged() {
|
||
if (root._pendingRestore && !root._inRestore && Math.abs(resultsList.contentY - root._savedContentY) > 1) {
|
||
root._inRestore = true
|
||
resultsList.contentY = root._savedContentY
|
||
root._inRestore = false
|
||
}
|
||
}
|
||
}
|
||
|
||
delegate: Rectangle {
|
||
id: itemDelegate
|
||
width: resultsList.width
|
||
height: 38
|
||
radius: modelData.isHeader ? 0 : 8
|
||
color: modelData.isHeader ? "transparent"
|
||
: (starHovered || hovered) ? t.launcherHover
|
||
: (resultsList.currentIndex === index && index !== -1
|
||
? t.launcherSelected : "transparent")
|
||
clip: modelData.isAction
|
||
property bool hovered: false
|
||
property bool starHovered: false
|
||
|
||
Text {
|
||
visible: modelData.isHeader
|
||
anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter }
|
||
text: modelData.section || ""
|
||
color: t.launcherDim
|
||
font.pixelSize: 13
|
||
font.weight: Font.Bold
|
||
}
|
||
|
||
RowLayout {
|
||
visible: !modelData.isHeader
|
||
anchors.fill: parent
|
||
anchors.leftMargin: modelData.isAction ? 28 : 12
|
||
anchors.rightMargin: 12
|
||
spacing: 12
|
||
|
||
Item {
|
||
visible: !modelData.isAction
|
||
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 {
|
||
visible: modelData.isAction
|
||
text: "›"
|
||
color: t.launcherDim
|
||
font.pixelSize: 14
|
||
Layout.alignment: Qt.AlignVCenter
|
||
}
|
||
|
||
Text {
|
||
text: modelData.name
|
||
color: modelData.isAction ? t.launcherDim : t.launcherText
|
||
font.pixelSize: modelData.isAction ? 13 : 14
|
||
Layout.fillWidth: true
|
||
elide: Text.ElideRight
|
||
}
|
||
|
||
Text {
|
||
visible: !modelData.isAction && modelData.actions && modelData.actions.length > 0
|
||
text: root.expandedItemId === modelData.id ? "▲" : "▼"
|
||
color: t.launcherDim
|
||
font.pixelSize: 9
|
||
Layout.alignment: Qt.AlignVCenter
|
||
}
|
||
|
||
Item {
|
||
visible: !modelData.isAction
|
||
width: 26; height: 26
|
||
Layout.alignment: Qt.AlignVCenter
|
||
|
||
Text {
|
||
id: starText
|
||
anchors.centerIn: parent
|
||
text: root.favoriteIds.includes(modelData.id) ? "★" : "☆"
|
||
font.pixelSize: 18
|
||
|
||
readonly property bool isFav: root.favoriteIds.includes(modelData.id)
|
||
color: starHovered ? t.launcherStarHover
|
||
: (isFav ? t.launcherAccent : t.launcherStar)
|
||
|
||
Behavior on color {
|
||
ColorAnimation { duration: 180; easing.type: Easing.OutCubic }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onEntered: parent.hovered = true
|
||
onExited: { parent.hovered = false; parent.starHovered = false }
|
||
onPositionChanged: mouse => {
|
||
parent.starHovered = !modelData.isHeader && !modelData.isAction && (mouse.x >= width - 46)
|
||
}
|
||
onClicked: mouse => {
|
||
if (modelData.isHeader) return
|
||
|
||
if (mouse.button === Qt.RightButton) {
|
||
if (!modelData.isAction && modelData.actions && modelData.actions.length > 0) {
|
||
root._savedContentY = resultsList.contentY
|
||
root._savedExpandId = modelData.id
|
||
root._expandingActions = modelData.id
|
||
root._pendingRestore = true
|
||
if (root.expandedItemId === modelData.id)
|
||
root.expandedItemId = ""
|
||
else
|
||
root.expandedItemId = modelData.id
|
||
}
|
||
return
|
||
}
|
||
|
||
if (modelData.isAction) {
|
||
root.executeAction(modelData)
|
||
return
|
||
}
|
||
|
||
if (mouse.x >= width - 46)
|
||
root.toggleFavorite(modelData.id)
|
||
else
|
||
root.launch(modelData)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|