Files
quickdots/launcher/Launcher.qml

607 lines
24 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}
}
}
}
}