mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-03 04:37:48 +02:00
576 lines
19 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|