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
-9
View File
@@ -91,15 +91,6 @@ QtObject {
property color border: root.composed("notifications.border", "notifications.border-alpha", root.accent, 1.0)
property color countdown: root.pick("notifications.countdown", root.accent)
}
readonly property QtObject launcher: QtObject {
property color background: root.composed("launcher.background", "launcher.background-alpha", root.background, 1.0)
property color text: root.pick("launcher.text", root.foreground)
property color border: root.composed("launcher.border", "launcher.border-alpha", root.foreground, 1.0)
property color scrim: root.composed("launcher.scrim", "launcher.scrim-alpha", root.background, 0.5)
property color selectedBackground: root.composed("launcher.selected-background", "launcher.selected-background-alpha", root.foreground, 0.08)
property color selectedText: root.pick("launcher.selected-text", root.accent)
property color selectedBorder: root.composed("launcher.selected-border", "launcher.selected-border-alpha", root.foreground, 0.0)
}
readonly property QtObject menu: QtObject {
property color background: root.composed("menu.background", "menu.background-alpha", root.background, 1.0)
property color text: root.pick("menu.text", root.foreground)
+1 -1
View File
@@ -309,7 +309,7 @@ QtObject {
return fallback
}
// The launcher, menu, polkit, emojis, and clipboard surfaces honor an
// The menu, polkit, emojis, and clipboard surfaces honor an
// OMARCHY_MENU_FONT override for users who want a different family on the
// summoned popups than on the bar. Resolved once at startup; an empty env
// value falls back to the shared fontconfig alias.
-1
View File
@@ -23,7 +23,6 @@ shell/
BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
plugins/
bar/ first-party plugins (see plugins/README.md)
launcher/
image-picker/
menu/
notifications/
+5 -2
View File
@@ -48,8 +48,10 @@ Item {
BorderSurface {
id: card
width: Math.min(parent.width - Style.space(96), Style.space(370))
height: Style.space(132)
width: Math.min(parent.width - Style.space(32), Style.space(370))
// Grows with the wrapped message so narrow hosts (like the menu card)
// don't squeeze the text into the buttons.
height: card.contentTopInset + card.contentBottomInset + messageText.implicitHeight + Style.space(20) + Style.space(34)
anchors.centerIn: parent
color: root.background
borderSpec: Border.flat(root.selectedText, Style.normalBorderWidth)
@@ -66,6 +68,7 @@ Item {
anchors.leftMargin: card.contentLeftInset
Text {
id: messageText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
-9
View File
@@ -14,7 +14,6 @@ User-installed plugins live alongside these conceptually but on disk under
| Plugin | id | kinds | entry point |
|---------------|---------------------------|-------------------------|---------------------------------------|
| Bar | `omarchy.bar` | `bar` | `bar/Bar.qml` |
| Launcher | `omarchy.launcher` | `overlay` | `launcher/Launcher.qml` |
| Image picker | `omarchy.image-picker` | `overlay` | `image-picker/ImagePicker.qml` |
| Emojis | `omarchy.emojis` | `overlay` | `emojis/Emojis.qml` |
| Clipboard mgr | `omarchy.clipboard` | `overlay` | `clipboard/Clipboard.qml` |
@@ -49,14 +48,6 @@ providing [`config/omarchy/shell.json`](../../config/omarchy/shell.json) when
the user has no file). See [`bar/README.md`](bar/README.md) for the widget catalogue
and customization schema.
## Launcher
Quickshell-powered launcher. It uses Quickshell's native
`DesktopEntries` model for discovery/activation and renders inside the
long-running shell with the legacy launcher card dimensions, colors, row
spacing, icon sizing, and keyboard behavior. Summoned directly over shell IPC
by the `SUPER + SPACE` binding and the Omarchy menu Apps row.
## Image picker
Fullscreen image-grid selector overlay. Used by `omarchy-menu-images`
-665
View File
@@ -1,665 +0,0 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
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 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
// Maps an icon name to a file on disk (e.g. "omacut" -> ".../apps/omacut.svg").
// Used as a fallback for icons that Qt's themed lookup misses because they were
// installed after this process started (its icon cache never re-scans). Refreshed
// whenever the app list changes, so newly installed apps get their icon live.
property var iconIndex: ({})
property var pendingIconIndex: ({})
// 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.filterText = payload.query || ""
root.selectedIndex = 0
root.cursorActive = true
root.disarmHover()
root.opened = true
root.rebuildDisplay()
// 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 (!iconIndexScan.running) iconIndexScan.running = true
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.opened = false
}
function dismiss() {
root.deleteConfirmOpen = false
root.deleteEntry = null
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)
// Prefer the context-limited app/device index. An unconstrained themed
// lookup can resolve an app name such as "zoom" to an action icon instead.
var found = root.iconIndex[value]
if (found) return Util.fileUrl(found)
var themed = Quickshell.iconPath(value, true)
if (themed.length > 0) return themed
return Quickshell.iconPath("application-x-executable", 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 disarmHover() {
pointerGate.reset()
}
function selectFromPointer(index, item, mouse) {
if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true
root.selectedIndex = index
}
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 iconIndexScanCommand() {
// List app/device icons across the XDG icon dirs and /usr/share/pixmaps as
// "<path>" lines. Some desktop entries, such as Print Settings, use device
// icons like "printer" instead of app icons. SVGs are emitted before PNGs
// so the parser, which keeps the first hit per name, prefers scalable icons.
return [
'dirs="$HOME/.icons $HOME/.local/share/icons";',
'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;',
'for ext in svg png; do',
' for base in $dirs; do',
' [[ -d $base ]] && find "$base" \\( -path "*/apps/*" -o -path "*/devices/*" \\) -name "*.$ext" 2>/dev/null;',
' done;',
' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;',
'done'
].join(' ')
}
function indexIconLine(path) {
var value = String(path || "").trim()
if (value.length === 0) return
var slash = value.lastIndexOf("/")
var file = slash >= 0 ? value.slice(slash + 1) : value
var dot = file.lastIndexOf(".")
var name = dot > 0 ? file.slice(0, dot) : file
if (name.length > 0 && root.pendingIconIndex[name] === undefined)
root.pendingIconIndex[name] = value
}
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.disarmHover()
root.rebuildDisplay()
}
function select(delta) {
if (displayModel.count === 0) return
root.cursorActive = true
root.disarmHover()
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
var desktopId = String(entry.id || "")
if (!desktopId) return
root.beginLaunchFeedback(entry)
root.dismiss()
Util.execDetached("gtk-launch " + Util.shellQuote(desktopId))
}
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
root.disarmHover()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmDelete() {
var entry = root.deleteEntry
if (!entry) return
var desktopId = String(entry.id || "")
var name = root.entryName(entry)
var command = Util.shellQuote(root.omarchyPath + "/bin/omarchy-remove-launcher-entry") + " " + Util.shellQuote(desktopId) + " " + Util.shellQuote(name)
root.dismiss()
Util.execDetached(command)
}
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)
}
Process {
id: iconIndexScan
command: ["bash", "-lc", root.iconIndexScanCommand()]
stdout: SplitParser { onRead: function(line) { root.indexIconLine(line) } }
onStarted: root.pendingIconIndex = ({})
// Swapping the property re-evaluates every iconSource() binding, so
// newly found icons appear without rebuilding the list.
onExited: root.iconIndex = root.pendingIconIndex
}
// Coalesces bursts of app-list changes (a package install touches many
// entries) into a single rescan.
Timer {
id: iconIndexDebounce
interval: 750
onTriggered: if (!iconIndexScan.running) iconIndexScan.running = true
}
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("")
}
PointerMoveGate {
id: pointerGate
referenceItem: card
}
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
iconIndexDebounce.restart()
if (root.opened) root.rebuildDisplay()
}
}
Component.onCompleted: {
hiddenEntryScan.running = true
iconIndexScan.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 (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
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.disarmHover()
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.disarmHover()
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
Image {
id: appIcon
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
fillMode: Image.PreserveAspectFit
// Decode at physical pixels: IconImage uses the logical size,
// which leaves PNG icons upscaled and blurry on HiDPI displays.
sourceSize.width: root.iconSize * Screen.devicePixelRatio
sourceSize.height: root.iconSize * Screen.devicePixelRatio
source: root.iconSource(row.icon)
asynchronous: 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 {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.selectFromPointer(row.index, row, {
x: mouseArea.mouseX,
y: mouseArea.mouseY
})
onPositionChanged: function(mouse) {
root.selectFromPointer(row.index, row, mouse)
}
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
}
}
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"schemaVersion": 1,
"id": "omarchy.launcher",
"name": "Launcher",
"version": "1.0.0",
"author": "Omarchy",
"description": "A Quickshell-powered launcher",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Launcher.qml"
}
}
+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
+2
View File
@@ -261,6 +261,8 @@ function displayRow(items, itemOrder, checkedResults, entry, detail, score, sect
kind: entry.kind,
icon: entry.icon,
iconFont: entry.iconFont || "",
appIcon: entry.appIcon || "",
appId: entry.appId || "",
label: labelFor(entry, checkedResults),
target: target,
detail: detail || "",
+258
View File
@@ -0,0 +1,258 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import "AppSearch.js" as AppSearch
// Shared desktop-application library: the sorted entry list with hidden-entry
// filtering, the icon fallback index, launch feedback, and entry removal.
// Injected as shell.appLibrary; the menu's Apps submenu is the consumer.
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var configuredHiddenEntryIds: ({})
property var desktopHiddenEntryIds: ({})
// Maps an icon name to a file on disk (e.g. "omacut" -> ".../apps/omacut.svg").
// Used as a fallback for icons that Qt's themed lookup misses because they were
// installed after this process started (its icon cache never re-scans). Refreshed
// whenever the app list changes, so newly installed apps get their icon live.
property var iconIndex: ({})
property var pendingIconIndex: ({})
property int launchSerial: 0
property int launchToplevelCount: 0
property var launchActiveToplevel: null
property bool launchOsdOpen: false
property string launchOsdMessage: ""
// Emitted whenever the visible application set may have changed: desktop
// entries appeared or vanished, or the hidden-entry filters reloaded.
signal appsChanged()
function entryName(entry) {
return AppSearch.entryName(entry)
}
function entrySubtext(entry) {
return AppSearch.entrySubtext(entry)
}
function isHiddenEntry(entry) {
var id = String((entry && entry.id) || "")
return root.configuredHiddenEntryIds[id] === true || root.desktopHiddenEntryIds[id] === true
}
function sortedEntries(query) {
var values = DesktopEntries.applications.values || []
return AppSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) })
}
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)
// Prefer the context-limited app/device index. An unconstrained themed
// lookup can resolve an app name such as "zoom" to an action icon instead.
var found = root.iconIndex[value]
if (found) return Util.fileUrl(found)
var themed = Quickshell.iconPath(value, true)
if (themed.length > 0) return themed
return Quickshell.iconPath("application-x-executable", true)
}
// The shell may start before first-install packages have finished placing
// their icons; consumers call this when they open so icons appear live.
function refreshIcons() {
if (!iconIndexScan.running) iconIndexScan.running = true
}
function launch(desktopId, name) {
var id = String(desktopId || "")
if (!id) return
root.beginLaunchFeedback(name)
Util.execDetached("gtk-launch " + Util.shellQuote(id))
}
function remove(desktopId, name) {
var id = String(desktopId || "")
if (!id) return
Util.execDetached(Util.shellQuote(root.omarchyPath + "/bin/omarchy-remove-launcher-entry") + " " + Util.shellQuote(id) + " " + Util.shellQuote(String(name || id)))
}
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
root.appsChanged()
}
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
root.appsChanged()
}
function iconIndexScanCommand() {
// List app/device icons across the XDG icon dirs and /usr/share/pixmaps as
// "<path>" lines. Some desktop entries, such as Print Settings, use device
// icons like "printer" instead of app icons. SVGs are emitted before PNGs
// so the parser, which keeps the first hit per name, prefers scalable icons.
return [
'dirs="$HOME/.icons $HOME/.local/share/icons";',
'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;',
'for ext in svg png; do',
' for base in $dirs; do',
' [[ -d $base ]] && find "$base" \\( -path "*/apps/*" -o -path "*/devices/*" \\) -name "*.$ext" 2>/dev/null;',
' done;',
' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;',
'done'
].join(' ')
}
function indexIconLine(path) {
var value = String(path || "").trim()
if (value.length === 0) return
var slash = value.lastIndexOf("/")
var file = slash >= 0 ? value.slice(slash + 1) : value
var dot = file.lastIndexOf(".")
var name = dot > 0 ? file.slice(0, dot) : file
if (name.length > 0 && root.pendingIconIndex[name] === undefined)
root.pendingIconIndex[name] = value
}
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/services/hidden-entries.sh"
return Util.shellQuote(script) + " " + Util.shellQuote(desktop)
}
function toplevelCount() {
try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 }
}
function beginLaunchFeedback(name) {
root.launchSerial++
root.launchToplevelCount = root.toplevelCount()
root.launchActiveToplevel = ToplevelManager.activeToplevel
root.launchOsdOpen = false
root.launchOsdMessage = "Launching " + String(name || "application") + "…"
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)
}
QtObject {
id: hiddenEntryOutput
property string text: ""
}
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)
}
Process {
id: iconIndexScan
command: ["bash", "-lc", root.iconIndexScanCommand()]
stdout: SplitParser { onRead: function(line) { root.indexIconLine(line) } }
onStarted: root.pendingIconIndex = ({})
// Swapping the property re-evaluates every iconSource() binding, so
// newly found icons appear without rebuilding the list.
onExited: root.iconIndex = root.pendingIconIndex
}
// Coalesces bursts of app-list changes (a package install touches many
// entries) into a single rescan.
Timer {
id: iconIndexDebounce
interval: 750
onTriggered: if (!iconIndexScan.running) iconIndexScan.running = true
}
FileView {
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
iconIndexDebounce.restart()
root.appsChanged()
}
}
Component.onCompleted: {
hiddenEntryScan.running = true
iconIndexScan.running = true
}
}
+1
View File
@@ -17,6 +17,7 @@ ShellRoot {
// own empty copies.
property PluginRegistry pluginRegistry: PluginRegistry { }
property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { }
property AppLibrary appLibrary: AppLibrary { }
property string home: Quickshell.env("HOME")