noctalia-shell/Modules/Launcher/Launcher.qml

524 lines
No EOL
19 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Services
import qs.Widgets
import "../../Helpers/FuzzySort.js" as Fuzzysort
NPanel {
id: root
panelWidth: Math.min(700 * scaling, screen?.width * 0.75)
panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
// Positioning derives from Settings.data.bar.position for vertical (top/bottom)
// and from Settings.data.appLauncher.position for horizontal vs center.
// Options: center, top_left, top_right, bottom_left, bottom_right
readonly property string launcherPosition: Settings.data.appLauncher.position
panelAnchorCentered: launcherPosition === "center"
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left"))
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right"))
// Properties
property string searchText: ""
// Add function to set search text programmatically
function setSearchText(text) {
searchText = text
if (searchInput) {
searchInput.text = text
searchInput.cursorPosition = text.length
searchInput.forceActiveFocus()
}
}
onOpened: {
// Reset state when panel opens to avoid sticky modes
if (searchText === "") {
searchText = ""
selectedIndex = 0
}
if (searchInput) {
searchInput.forceActiveFocus()
}
}
// Import modular components
Calculator {
id: calculator
}
ClipboardHistory {
id: clipboardHistory
}
// Poll cliphist while in clipboard mode to keep entries fresh
Timer {
id: clipRefreshTimer
interval: 2000
repeat: true
running: Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")
onTriggered: clipboardHistory.refresh()
}
// Properties
property var desktopEntries: DesktopEntries.applications.values
property int selectedIndex: 0
// Refresh clipboard when user starts typing clipboard commands
onSearchTextChanged: {
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
clipboardHistory.refresh()
}
}
// Main filtering logic
property var filteredEntries: {
// Explicit dependency so changes to items/decoded images retrigger this binding
const _clipItems = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.items : []
const _clipRev = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.revision : 0
var query = searchText ? searchText.toLowerCase() : ""
if (Settings.data.appLauncher.enableClipboardHistory && query.startsWith(">clip")) {
return clipboardHistory.processQuery(query, _clipItems)
}
if (!desktopEntries || desktopEntries.length === 0) {
return []
}
// Filter out entries that shouldn't be displayed
var visibleEntries = desktopEntries.filter(entry => {
if (!entry || entry.noDisplay) {
return false
}
return true
})
var results = []
// Handle special commands
if (query === ">") {
results.push({
"isCommand": true,
"name": ">calc",
"content": "Calculator - evaluate mathematical expressions",
"icon": "calculate",
"execute": executeCalcCommand
})
if (Settings.data.appLauncher.enableClipboardHistory) {
results.push({
"isCommand": true,
"name": ">clip",
"content": "Clipboard history - browse and restore clipboard items",
"icon": "content_paste",
"execute": executeClipCommand
})
}
return results
}
// Handle calculator
if (query.startsWith(">calc")) {
return calculator.processQuery(query, "calc")
}
// Handle direct math expressions after ">"
if (query.startsWith(">") && query.length > 1 && (!Settings.data.appLauncher.enableClipboardHistory
|| !query.startsWith(">clip")) && !query.startsWith(">calc")) {
const mathResults = calculator.processQuery(query, "direct")
if (mathResults.length > 0) {
return mathResults
}
// If math evaluation fails, fall through to regular search
}
// Regular app search
if (!query) {
results = results.concat(visibleEntries.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase())
}))
} else {
var fuzzyResults = Fuzzysort.go(query, visibleEntries, {
"keys": ["name", "comment", "genericName"]
})
results = results.concat(fuzzyResults.map(function (r) {
return r.obj
}))
}
return results
}
// Command execution functions
function executeCalcCommand() {
setSearchText(">calc ")
}
function executeClipCommand() {
setSearchText(">clip ")
}
// Navigation functions
function selectNext() {
if (filteredEntries.length > 0) {
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
}
}
function selectPrev() {
if (filteredEntries.length > 0) {
selectedIndex = Math.max(selectedIndex - 1, 0)
}
}
function selectNextPage() {
if (filteredEntries.length > 0) {
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
selectedIndex = Math.min(selectedIndex + page, filteredEntries.length - 1)
}
}
function selectPrevPage() {
if (filteredEntries.length > 0) {
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
selectedIndex = Math.max(selectedIndex - page, 0)
}
}
function activateSelected() {
if (filteredEntries.length === 0)
return
var modelData = filteredEntries[selectedIndex]
if (modelData && modelData.execute) {
if (modelData.isCommand) {
modelData.execute()
return
} else {
modelData.execute()
}
root.close()
}
}
Component.onCompleted: {
Logger.log("Launcher", "Component completed")
Logger.log("Launcher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
if (typeof DesktopEntries !== 'undefined') {
Logger.log("Launcher", "DesktopEntries.entries:",
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
}
// Start clipboard refresh immediately on open if enabled
if (Settings.data.appLauncher.enableClipboardHistory) {
clipboardHistory.refresh()
}
}
// Main content container
panelContent: Rectangle {
color: Color.transparent
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL * scaling
spacing: Style.marginM * scaling
// Search bar
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Style.barHeight * scaling
Layout.bottomMargin: Style.marginM * scaling
radius: Style.radiusM * scaling
color: Color.mSurface
border.color: searchInput.activeFocus ? Color.mSecondary : Color.mOutline
border.width: Math.max(1, searchInput.activeFocus ? Style.borderM * scaling : Style.borderS * scaling)
Item {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
NIcon {
id: searchIcon
text: "search"
font.pointSize: Style.fontSizeXL * scaling
color: searchInput.activeFocus ? Color.mPrimary : Color.mOnSurface
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
TextField {
id: searchInput
placeholderText: searchText === "" ? "Search applications... (use > to view commands)" : "Search applications..."
color: Color.mOnSurface
placeholderTextColor: Color.mOnSurfaceVariant
background: null
font.pointSize: Style.fontSizeL * scaling
anchors.left: searchIcon.right
anchors.leftMargin: Style.marginS * scaling
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: searchText
onTextChanged: {
// Update the parent searchText property
if (searchText !== text) {
searchText = text
}
// Defer selectedIndex reset to avoid binding loops
Qt.callLater(() => selectedIndex = 0)
}
selectedTextColor: Color.mOnSurface
selectionColor: Color.mPrimary
padding: 0
verticalAlignment: TextInput.AlignVCenter
leftPadding: 0
rightPadding: 0
topPadding: 0
bottomPadding: 0
Component.onCompleted: {
// Focus the search bar by default
Qt.callLater(() => {
selectedIndex = 0
searchInput.forceActiveFocus()
})
}
Keys.onDownPressed: selectNext()
Keys.onUpPressed: selectPrev()
Keys.onEnterPressed: activateSelected()
Keys.onReturnPressed: activateSelected()
Keys.onEscapePressed: root.close()
Keys.onPressed: event => {
if (event.key === Qt.Key_PageDown) {
appsList.cancelFlick()
root.selectNextPage()
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
appsList.cancelFlick()
root.selectPrevPage()
event.accepted = true
} else if (event.key === Qt.Key_Home) {
appsList.cancelFlick()
selectedIndex = 0
event.accepted = true
} else if (event.key === Qt.Key_End) {
appsList.cancelFlick()
if (filteredEntries.length > 0) {
selectedIndex = filteredEntries.length - 1
}
event.accepted = true
}
if (event.modifiers & Qt.ControlModifier) {
switch (event.key) {
case Qt.Key_J:
appsList.cancelFlick()
root.selectNext()
event.accepted = true
break
case Qt.Key_K:
appsList.cancelFlick()
root.selectPrev()
event.accepted = true
break
}
}
}
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
}
// Applications list
ListView {
id: appsList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
spacing: Style.marginXXS * scaling
model: filteredEntries
currentIndex: selectedIndex
boundsBehavior: Flickable.StopAtBounds
maximumFlickVelocity: 2500
flickDeceleration: 2000
onCurrentIndexChanged: {
cancelFlick()
if (currentIndex >= 0) {
positionViewAtIndex(currentIndex, ListView.Contain)
}
}
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
delegate: Rectangle {
width: appsList.width - Style.marginS * scaling
height: 65 * scaling
radius: Style.radiusM * scaling
property bool isSelected: index === selectedIndex
color: (appCardArea.containsMouse || isSelected) ? Color.mSecondary : Color.mSurface
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.color {
ColorAnimation {
duration: Style.animationFast
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
}
}
RowLayout {
anchors.fill: parent
anchors.margins: Style.marginM * scaling
spacing: Style.marginM * scaling
// App/clipboard icon with background
Rectangle {
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
radius: Style.radiusS * scaling
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|| (iconImg.status === Image.Ready && iconImg.source !== ""
&& iconImg.status !== Image.Error && iconImg.source !== "")
visible: !searchText.startsWith(">calc")
// Clipboard image display (pull from cache)
Image {
id: clipboardImage
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
visible: modelData.type === 'image'
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
}
IconImage {
id: iconImg
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
asynchronous: true
source: modelData.isCalculator ? "" : modelData.isClipboard ? "" : modelData.isCommand ? modelData.icon : Icons.iconFromName(
modelData.icon,
"application-x-executable")
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand || parent.iconLoaded)
&& modelData.type !== 'image'
}
Rectangle {
anchors.fill: parent
anchors.margins: Style.marginXS * scaling
radius: Style.radiusXS * scaling
color: Color.mPrimary
opacity: Style.opacityMedium
visible: !parent.iconLoaded
}
NText {
anchors.centerIn: parent
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
font.pointSize: Style.fontSizeXXL * scaling
font.weight: Style.fontWeightBold
color: Color.mPrimary
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
// App info
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginXXS * scaling
NText {
text: modelData.name || "Unknown"
font.pointSize: Style.fontSizeL * scaling
font.weight: Style.fontWeightBold
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
}
NText {
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
font.pointSize: Style.fontSizeM * scaling
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
visible: text !== ""
}
}
}
MouseArea {
id: appCardArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
selectedIndex = index
activateSelected()
}
}
}
}
// No results message
NText {
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
font.pointSize: Style.fontSizeL * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: filteredEntries.length === 0
}
// Results count
NText {
text: searchText.startsWith(
">clip") ? (Settings.data.appLauncher.enableClipboardHistory ? `${filteredEntries.length} clipboard item${filteredEntries.length !== 1 ? 's' : ''}` : `Clipboard history is disabled`) : searchText.startsWith(
">calc") ? `${filteredEntries.length} result${filteredEntries.length
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length
!== 1 ? 's' : ''}`
font.pointSize: Style.fontSizeXS * scaling
color: Color.mOnSurface
horizontalAlignment: Text.AlignHCenter
Layout.fillWidth: true
visible: searchText.trim() !== ""
}
}
}
}