Files

576 lines
19 KiB
QML

import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick
import qs.Commons
import qs.Ui
import "LauncherSearch.js" as LauncherSearch
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var shell: null
property var manifest: null
property bool opened: false
property string placeholder: "\uf002 Search..."
property string filterText: ""
property int selectedIndex: 0
property bool cursorActive: true
property bool hoverArmed: false
property var filteredEntries: []
property int launchSerial: 0
property int launchToplevelCount: 0
property var launchActiveToplevel: null
property bool launchOsdOpen: false
property string launchOsdMessage: ""
property var configuredHiddenEntryIds: ({})
property var desktopHiddenEntryIds: ({})
property bool deleteConfirmOpen: false
property var deleteEntry: null
// Bound to the central [launcher] section in shell.toml via Color.qml.
// Each color already includes its alpha companion (composed in the
// singleton), so consumers can drop them straight into a Rectangle.
property color background: Color.launcher.background
property color foreground: Color.launcher.text
property color border: Color.launcher.border
property var borderSpec: Border.surfaceSpec("launcher", "border", border, 2)
property color scrim: Color.launcher.scrim
property color selectedBackground: Color.launcher.selectedBackground
property color selectedText: Color.launcher.selectedText
property color selectedBorder: Color.launcher.selectedBorder
property var selectedBorderSpec: Border.surfaceSpec("launcher", "selected-border", selectedBorder, 0)
readonly property real rowReservedBorderLeft: Border.left(selectedBorderSpec)
readonly property real rowReservedBorderRight: Border.right(selectedBorderSpec)
property string fontFamily: Style.font.menuFamily
property int cardWidth: 644
property int cardHeight: 400
property int contentMargin: 20
property int contentSpacing: 10
property int searchHeight: 44
property int rowHeight: 50
property int iconSlotWidth: 44
property int iconSize: 24
readonly property int listHeight: cardHeight - contentMargin * 2 - searchHeight - contentSpacing
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.placeholder = payload.placeholder || "\uf002 Search..."
root.cardWidth = Math.max(300, Number(payload.width || 644))
var requestedListHeight = Number(payload.listHeight || payload.maxHeight || 0)
root.cardHeight = requestedListHeight > 0
? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight
: 400
root.opened = true
root.filterText = payload.query || ""
root.selectedIndex = 0
root.cursorActive = true
root.hoverArmed = false
root.rebuildDisplay()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.deleteConfirmOpen = false
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide((root.manifest && root.manifest.id) || "omarchy.launcher")
}
function iconSource(icon) {
var value = String(icon || "")
if (value.length === 0) return Quickshell.iconPath("application-x-executable", true)
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
if (value.charAt(0) === "/") return Util.fileUrl(value)
return Quickshell.iconPath(value, true)
}
function entryName(entry) {
return LauncherSearch.entryName(entry)
}
function entrySubtext(entry) {
return LauncherSearch.entrySubtext(entry)
}
function entrySortKey(entry) {
return LauncherSearch.entrySortKey(entry)
}
function toplevelCount() {
try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 }
}
function entrySearchText(entry) {
return LauncherSearch.entrySearchText(entry)
}
function isHiddenEntry(entry) {
var id = String((entry && entry.id) || "")
return root.configuredHiddenEntryIds[id] === true || root.desktopHiddenEntryIds[id] === true
}
function normalizeDesktopId(id) {
var value = String(id || "").trim()
if (value.slice(-8) === ".desktop") value = value.slice(0, -8)
return value
}
function loadConfiguredHides(rawText) {
var next = ({})
var lines = String(rawText || "").split(/\n/)
for (var i = 0; i < lines.length; i++) {
var id = root.normalizeDesktopId(lines[i])
if (id.length > 0) next[id] = true
}
root.configuredHiddenEntryIds = next
if (root.opened) root.rebuildDisplay()
}
function loadDesktopHiddenEntries(rawText) {
var next = ({})
var lines = String(rawText || "").split(/\n/)
for (var i = 0; i < lines.length; i++) {
var id = root.normalizeDesktopId(lines[i])
if (id.length > 0) next[id] = true
}
root.desktopHiddenEntryIds = next
if (root.opened) root.rebuildDisplay()
}
function hiddenEntryScanCommand() {
var desktop = [Quickshell.env("XDG_CURRENT_DESKTOP"), Quickshell.env("XDG_SESSION_DESKTOP"), Quickshell.env("DESKTOP_SESSION")].filter(function(v) { return String(v || "").length > 0 }).join(":")
var script = root.omarchyPath + "/shell/plugins/launcher/hidden-entries.sh"
return Util.shellQuote(script) + " " + Util.shellQuote(desktop)
}
function fuzzyScore(entry, query) {
return LauncherSearch.fuzzyScore(entry, query)
}
function sortedEntries(query) {
var values = DesktopEntries.applications.values || []
return LauncherSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) })
}
function rebuildDisplay() {
displayModel.clear()
var rows = root.sortedEntries(root.filterText)
var entries = []
var count = Math.min(rows.length, 256)
for (var i = 0; i < count; i++) {
var entry = rows[i].entry
entries.push(entry)
displayModel.append({
name: root.entryName(entry),
subtext: root.entrySubtext(entry),
icon: String(entry.icon || "")
})
}
root.filteredEntries = entries
if (displayModel.count === 0) root.selectedIndex = 0
else if (root.selectedIndex >= displayModel.count) root.selectedIndex = displayModel.count - 1
else if (root.selectedIndex < 0) root.selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
root.cursorActive = true
root.rebuildDisplay()
}
function select(delta) {
if (displayModel.count === 0) return
root.cursorActive = true
root.selectedIndex = (root.selectedIndex + delta + displayModel.count) % displayModel.count
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function activateIndex(index) {
if (root.deleteConfirmOpen) return
if (index < 0 || index >= root.filteredEntries.length) return
var entry = root.filteredEntries[index]
if (!entry) return
root.beginLaunchFeedback(entry)
root.dismiss()
entry.execute()
}
function requestDeleteIndex(index) {
if (index < 0 || index >= root.filteredEntries.length) return
var entry = root.filteredEntries[index]
if (!entry) return
root.deleteEntry = entry
deleteConfirm.selectedIndex = 1
root.deleteConfirmOpen = true
}
function cancelDelete() {
root.deleteConfirmOpen = false
root.deleteEntry = null
deleteConfirm.selectedIndex = 1
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmDelete() {
var entry = root.deleteEntry
if (!entry) return
var desktopId = String(entry.id || "")
var name = root.entryName(entry)
root.cancelDelete()
root.dismiss()
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-remove-launcher-entry", desktopId, name])
}
function beginLaunchFeedback(entry) {
root.launchSerial++
root.launchToplevelCount = root.toplevelCount()
root.launchActiveToplevel = ToplevelManager.activeToplevel
root.launchOsdOpen = false
root.launchOsdMessage = "Launching " + root.entryName(entry) + "…"
launchDelay.restart()
launchTimeout.restart()
}
function closeLaunchFeedback(serial) {
if (serial !== root.launchSerial) return
launchDelay.stop()
launchTimeout.stop()
if (root.launchOsdOpen) {
Quickshell.execDetached(["omarchy-shell", "osd", "close"])
root.launchOsdOpen = false
}
}
function maybeFinishLaunchFeedback() {
if (!launchDelay.running && !launchTimeout.running && !root.launchOsdOpen) return
if (root.toplevelCount() <= root.launchToplevelCount && ToplevelManager.activeToplevel === root.launchActiveToplevel) return
root.closeLaunchFeedback(root.launchSerial)
}
ListModel { id: displayModel }
Process {
id: hiddenEntryScan
command: ["bash", "-lc", root.hiddenEntryScanCommand()]
stdout: SplitParser { onRead: function(line) { hiddenEntryOutput.text += line + "\n" } }
onStarted: hiddenEntryOutput.text = ""
onExited: root.loadDesktopHiddenEntries(hiddenEntryOutput.text)
}
QtObject {
id: hiddenEntryOutput
property string text: ""
}
FileView {
id: launcherHidesFile
path: root.omarchyPath + "/default/omarchy/launcher.hides"
watchChanges: true
printErrors: false
onLoaded: root.loadConfiguredHides(text())
onFileChanged: root.loadConfiguredHides(text())
onLoadFailed: root.loadConfiguredHides("")
}
Connections {
target: ToplevelManager.toplevels
function onValuesChanged() { root.maybeFinishLaunchFeedback() }
}
Connections {
target: ToplevelManager
function onActiveToplevelChanged() { root.maybeFinishLaunchFeedback() }
}
Timer {
id: launchDelay
interval: 2000
onTriggered: {
if (root.toplevelCount() > root.launchToplevelCount || ToplevelManager.activeToplevel !== root.launchActiveToplevel) return
root.launchOsdOpen = true
Quickshell.execDetached(["omarchy-shell", "osd", "show", JSON.stringify({ icon: "󱓞", message: root.launchOsdMessage, duration: 0 })])
}
}
Timer {
id: launchTimeout
interval: 15000
onTriggered: root.closeLaunchFeedback(root.launchSerial)
}
Connections {
target: DesktopEntries.applications
function onValuesChanged() {
hiddenEntryScan.running = true
if (root.opened) root.rebuildDisplay()
}
}
Component.onCompleted: hiddenEntryScan.running = true
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-launcher"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
BorderSurface {
id: card
width: Math.min(root.cardWidth, panel.width - Style.gapsOut * 2)
height: Math.min(root.cardHeight, panel.height - Style.gapsOut * 2)
radius: Style.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
clip: true
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
z: root.deleteConfirmOpen ? 20 : 0
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (root.deleteConfirmOpen) {
if (deleteConfirm.handleKey(event)) event.accepted = true
return
}
if (event.key === Qt.Key_Escape) {
if (root.filterText.length > 0) root.setFilter("")
else root.dismiss()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
event.accepted = true
} else if (event.key === Qt.Key_Up) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-6)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(6)
event.accepted = true
} else if (event.key === Qt.Key_Home) {
if (displayModel.count > 0) {
root.cursorActive = true
root.selectedIndex = 0
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
event.accepted = true
} else if (event.key === Qt.Key_End) {
if (displayModel.count > 0) {
root.cursorActive = true
root.selectedIndex = displayModel.count - 1
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
root.activateIndex(root.selectedIndex)
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
root.requestDeleteIndex(root.selectedIndex)
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
ConfirmDialog {
id: deleteConfirm
anchors.fill: parent
opened: root.deleteConfirmOpen
z: 10
message: "Do you want to uninstall " + root.entryName(root.deleteEntry) + "?"
confirmText: "Uninstall"
background: root.background
foreground: root.foreground
scrim: root.scrim
selectedBackground: root.selectedBackground
selectedText: root.selectedText
fontFamily: root.fontFamily
cornerRadius: Style.cornerRadius
onCanceled: root.cancelDelete()
onConfirmed: root.confirmDelete()
}
}
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.searchHeight
radius: 0
color: root.background
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || root.placeholder
color: root.foreground
opacity: root.filterText ? 1 : 0.5
font.family: root.fontFamily
font.pixelSize: 18
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: parent.height - root.searchHeight - root.contentSpacing
ListView {
id: resultList
anchors.fill: parent
model: displayModel
clip: true
spacing: 0
boundsBehavior: Flickable.StopAtBounds
delegate: BorderSurface {
id: row
required property int index
required property string name
required property string subtext
required property string icon
readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex
width: ListView.view.width
height: root.rowHeight
radius: 0
color: row.hasCursor ? root.selectedBackground : "transparent"
borderSpec: row.hasCursor ? root.selectedBorderSpec : Border.none()
Item {
id: iconSlot
anchors.left: parent.left
anchors.leftMargin: root.rowReservedBorderLeft + 14
anchors.verticalCenter: parent.verticalCenter
width: root.iconSlotWidth
height: parent.height
IconImage {
id: appIcon
anchors.centerIn: parent
implicitSize: root.iconSize
width: root.iconSize
height: root.iconSize
source: root.iconSource(row.icon)
asynchronous: true
mipmap: true
}
Text {
anchors.centerIn: parent
visible: appIcon.status === Image.Error
text: "?"
color: row.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: 18
}
}
Text {
anchors.left: iconSlot.right
anchors.leftMargin: 14
anchors.right: parent.right
anchors.rightMargin: root.rowReservedBorderRight + 14
anchors.verticalCenter: parent.verticalCenter
text: row.name
color: row.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: 18
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: function(mouse) {
root.hoverArmed = true
root.cursorActive = true
root.selectedIndex = row.index
}
onContainsMouseChanged: if (containsMouse && root.hoverArmed) {
root.cursorActive = true
root.selectedIndex = row.index
}
onClicked: {
root.cursorActive = true
root.selectedIndex = row.index
root.activateIndex(row.index)
}
}
}
}
Text {
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: 14
visible: displayModel.count === 0
text: "No Results"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: 18
}
}
}
}
}
}