Combine the Omarchy menu and the launcher

Now that we can deep search, they don't need to be different
This commit is contained in:
David Heinemeier Hansson
2026-07-23 15:04:22 -07:00
parent 347997871e
commit 7a9947a732
23 changed files with 576 additions and 876 deletions
+167 -3
View File
@@ -11,6 +11,8 @@ Item {
// Injected by omarchy-shell when this plugin is summoned.
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var shell: null
property var manifest: null
// Plugin lifecycle hooks. The host calls open(payloadJson) after
// `omarchy-shell shell summon omarchy.menu ...` and close() when hidden.
@@ -72,6 +74,13 @@ Item {
property var providersLoaded: ({})
property var providerQueue: []
property int providerRevision: 0
// Shared application engine (entries, hidden filters, icons, launch,
// removal), owned by the shell and also used by the standalone launcher.
readonly property var appLibrary: root.shell ? root.shell.appLibrary : null
property bool deleteConfirmOpen: false
property var deleteTarget: null
onOpenedChanged: if (!opened) { deleteConfirmOpen = false; deleteTarget = null }
// Bound to the central [menu] 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.
@@ -231,9 +240,64 @@ Item {
return MenuModel.slugify(value)
}
// The apps provider is QML-native: rows come from the shared AppLibrary
// (DesktopEntries) instead of a bash enumeration, so they carry image
// icons, launch feedback, and uninstall support like the launcher.
function mergeAppRows() {
if (!root.appLibrary) return
var nextOrder = []
for (var i = 0; i < root.itemOrder.length; i++) {
var id = root.itemOrder[i]
var existing = root.items[id]
if (existing && existing.kind === "app") delete root.items[id]
else nextOrder.push(id)
}
var rows = root.appLibrary.sortedEntries("")
for (var j = 0; j < rows.length; j++) {
var entry = rows[j].entry
var appId = String(entry.id || "")
if (!appId) continue
var itemId = "apps." + appId
var subtext = root.appLibrary.entrySubtext(entry)
var aliases = subtext ? [subtext] : []
try {
if (entry.keywords && typeof entry.keywords.join === "function") aliases = aliases.concat(entry.keywords)
} catch (e) { }
root.items[itemId] = {
id: itemId,
parent: "apps",
kind: "app",
icon: "",
appIcon: String(entry.icon || ""),
appId: appId,
label: root.appLibrary.entryName(entry),
title: "",
target: "",
description: subtext,
action: "",
provider: "",
aliases: aliases,
when: "",
checked: "",
order: nextOrder.length
}
nextOrder.push(itemId)
}
root.itemOrder = nextOrder
if (root.opened) root.rebuildDisplay()
}
function startProviderForMenu(id) {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return
if (entry.provider === "apps") {
root.providersLoaded[id] = true
root.mergeAppRows()
return
}
var spec = root.providers[entry.provider]
if (!spec) return
@@ -301,6 +365,12 @@ Item {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return
// Native providers don't touch providerProc, so they never need to queue.
if (entry.provider === "apps") {
root.startProviderForMenu(id)
return
}
if (providerProc.running) {
if (root.providerQueue.indexOf(id) < 0) root.providerQueue = root.providerQueue.concat([id])
return
@@ -403,6 +473,8 @@ Item {
kind: "dmenu",
icon: "",
iconFont: "",
appIcon: "",
appId: "",
label: label,
target: "",
detail: "",
@@ -544,6 +616,7 @@ Item {
}
function activateIndex(index, fromPointer) {
if (root.deleteConfirmOpen) return
if (root.dmenuActive) {
if (root.mode === "input") {
root.applyDmenuSelection(root.filterText)
@@ -559,11 +632,44 @@ Item {
var row = displayModel.get(index)
if (row.kind === "menu" || row.kind === "link") {
root.setActiveMenu(row.target || row.itemId, true, fromPointer)
} else if (row.kind === "app") {
var appId = row.appId
var label = row.label
applySerial = requestSerial
opened = false
filterText = ""
if (root.appLibrary) root.appLibrary.launch(appId, label)
} else {
root.applySelected(row.itemId, row.action)
}
}
function requestDeleteSelected() {
if (!root.cursorActive || root.selectedIndex < 0 || root.selectedIndex >= displayModel.count) return
var row = displayModel.get(root.selectedIndex)
if (!row || row.kind !== "app") return
root.deleteTarget = { appId: row.appId, label: row.label }
deleteConfirm.selectedIndex = 1
root.deleteConfirmOpen = true
}
function cancelDelete() {
root.deleteConfirmOpen = false
root.deleteTarget = null
deleteConfirm.selectedIndex = 1
root.disarmPointer()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmDelete() {
var target = root.deleteTarget
root.deleteConfirmOpen = false
root.deleteTarget = null
if (!target) return
root.cancel()
if (root.appLibrary) root.appLibrary.remove(target.appId, target.label)
}
function applyDmenuSelection(value) {
applySerial = requestSerial
opened = false
@@ -602,6 +708,9 @@ Item {
opened = true
rebuildDisplay()
loadProviderForMenu(activeMenu)
// The shell may start before first-install packages have finished placing
// their icons. Refresh here even when the desktop entry list did not change.
if (root.appLibrary) root.appLibrary.refreshIcons()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
@@ -709,6 +818,13 @@ Item {
referenceItem: card
}
Connections {
target: root.appLibrary
function onAppsChanged() {
if (root.providersLoaded["apps"]) root.mergeAppRows()
}
}
// The JSONC sources are watched so live edits to the default file (or the
// user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take
// effect without restarting the shell.
@@ -837,11 +953,20 @@ Item {
Item {
id: keyCatcher
anchors.fill: parent
z: root.deleteConfirmOpen ? 20 : 0
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.deleteConfirmOpen) {
if (deleteConfirm.handleKey(event)) event.accepted = true
return
}
if (event.key === Qt.Key_Delete) {
root.requestDeleteSelected()
event.accepted = true
} else if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.cancel()
event.accepted = true
@@ -875,6 +1000,25 @@ Item {
event.accepted = true
}
}
ConfirmDialog {
id: deleteConfirm
anchors.fill: parent
opened: root.deleteConfirmOpen
z: 10
message: "Do you want to uninstall " + ((root.deleteTarget && root.deleteTarget.label) || "") + "?"
confirmText: "Uninstall"
background: root.background
foreground: root.foreground
scrim: root.scrim
selectedBackground: root.selectedBackground
selectedText: root.selectedText
fontFamily: root.fontFamily
cornerRadius: root.cornerRadius
onCanceled: root.cancelDelete()
onConfirmed: root.confirmDelete()
}
}
Column {
@@ -944,6 +1088,8 @@ Item {
required property string kind
required property string icon
required property string iconFont
required property string appIcon
required property string appId
required property string label
required property string target
required property string detail
@@ -952,7 +1098,8 @@ Item {
required property int childCount
readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex
readonly property bool hasIcon: row.icon.length > 0
readonly property bool isApp: row.kind === "app"
readonly property bool hasIcon: row.icon.length > 0 || row.isApp
width: ListView.view.width
height: root.rowHeightForDetail(row.detail)
@@ -973,7 +1120,7 @@ Item {
Text {
id: iconText
visible: row.hasIcon
visible: row.hasIcon && !row.isApp
text: row.icon
color: row.hasCursor ? root.selectedText : root.foreground
font.family: row.iconFont.length > 0 ? row.iconFont : root.fontFamily
@@ -986,6 +1133,23 @@ Item {
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
}
Image {
id: appIconImage
visible: row.isApp
width: Style.font.iconLarge
height: Style.font.iconLarge
fillMode: Image.PreserveAspectFit
// Decode at physical pixels — a logical-size decode leaves
// PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: width * Screen.devicePixelRatio
sourceSize.height: height * Screen.devicePixelRatio
source: row.isApp && root.appLibrary ? root.appLibrary.iconSource(row.appIcon) : ""
asynchronous: true
anchors.left: parent.left
anchors.leftMargin: root.rowReservedBorderLeft + Style.space(8) + (Style.space(36) - width) / 2
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
}
Column {
id: contentColumn
anchors.left: row.hasIcon ? iconText.right : parent.left