import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Widgets import QtQuick import qs.Commons 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 property int deleteConfirmIndex: 1 // 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 color scrim: Color.launcher.scrim property color selectedBackground: Color.launcher.selectedBackground property color selectedText: Color.launcher.selectedText property color selectedBorder: Color.launcher.selectedBorder 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 root.deleteConfirmIndex = 1 root.deleteConfirmOpen = true } function cancelDelete() { root.deleteConfirmOpen = false root.deleteEntry = null root.deleteConfirmIndex = 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() } Rectangle { 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 border.color: root.border border.width: 2 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 (event.key === Qt.Key_Escape) { root.cancelDelete() event.accepted = true } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { root.deleteConfirmIndex = root.deleteConfirmIndex === 0 ? 1 : 0 event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (root.deleteConfirmIndex === 0) root.cancelDelete() else root.confirmDelete() 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 } } Rectangle { anchors.fill: parent visible: root.deleteConfirmOpen z: 10 color: root.scrim MouseArea { anchors.fill: parent; onClicked: root.cancelDelete() } Rectangle { width: Math.min(parent.width - 96, 370) height: 132 anchors.centerIn: parent color: root.background border.color: root.selectedText border.width: 1 radius: Style.cornerRadius MouseArea { anchors.fill: parent; onClicked: {} } Item { anchors.fill: parent anchors.margins: 18 Text { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top text: "Do you want to uninstall " + root.entryName(root.deleteEntry) + "?" color: root.foreground font.family: root.fontFamily font.pixelSize: 16 wrapMode: Text.WordWrap } Row { anchors.right: parent.right anchors.bottom: parent.bottom spacing: 10 Repeater { model: ["Cancel", "Uninstall"] Rectangle { required property int index required property string modelData readonly property bool selected: root.deleteConfirmIndex === index readonly property bool destructive: index === 1 width: 88 height: 34 color: selected ? (destructive ? Util.alpha(Color.urgent, 0.22) : root.selectedBackground) : "transparent" border.color: destructive ? (selected ? Color.urgent : Util.alpha(Color.urgent, 0.56)) : (selected ? root.selectedText : Util.alpha(root.foreground, 0.38)) border.width: 1 radius: 0 Text { id: buttonText anchors.centerIn: parent text: modelData color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground) font.family: root.fontFamily font.pixelSize: 14 } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: root.deleteConfirmIndex = index onClicked: { if (index === 0) root.cancelDelete() else root.confirmDelete() } } } } } } } } } Column { anchors.fill: parent anchors.margins: root.contentMargin 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: Rectangle { 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" border.color: row.hasCursor ? root.selectedBorder : "transparent" border.width: (row.hasCursor && root.selectedBorder.a > 0) ? Math.max(1, Style.normalBorderWidth) : 0 Item { id: iconSlot anchors.left: parent.left anchors.leftMargin: 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: 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 } } } } } }