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
+1 -1
View File
@@ -7,7 +7,7 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation
-- Launcher, image selector, emojis, clipboard overlays, and keyboard-driven
-- panels should pop without compositor layer fades. Panels keep their own
-- QML opacity transition for normal open/close, and skip it for panel handoff.
hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-launcher|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel)$" }, no_anim = true, animation = "none" })
hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel)$" }, no_anim = true, animation = "none" })
-- Dev gallery is the main shell workbench; open it maximized like
-- SUPER+ALT+F so component previews have the whole workspace.
+1 -2
View File
@@ -1,9 +1,8 @@
o.bind("SUPER + SPACE", "Launch apps", "omarchy-shell shell toggle omarchy.launcher \"{}\"")
o.bind("SUPER + SPACE", "Omarchy menu", "omarchy-menu toggle")
o.bind("SUPER + CTRL + E", "Emojis", "omarchy-shell shell toggle omarchy.emojis")
o.bind("SUPER + CTRL + C", "Capture menu", "omarchy-menu toggle capture")
o.bind("SUPER + CTRL + O", "Toggle menu", "omarchy-menu toggle toggle")
o.bind("SUPER + CTRL + H", "Hardware menu", "omarchy-menu toggle hardware")
o.bind("SUPER + ALT + SPACE", "Omarchy menu", "omarchy-menu toggle root")
o.bind("SUPER + SHIFT + code:201", "Omarchy menu", "omarchy-menu toggle root")
o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system")
o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true })
+1 -1
View File
@@ -14,7 +14,7 @@
// checked shell condition; append ✓ when it succeeds
// Root Menu
"apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"action":"omarchy-shell shell summon omarchy.launcher"},
"apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps"},
"learn": {"icon":"󰧑","label":"Learn"},
"trigger": {"icon":"󱓞","label":"Trigger"},
"style": {"icon":"","label":"Style"},
-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")
+1 -1
View File
@@ -16,7 +16,7 @@ wait_until "omarchy-shell responds to ping" 60 omarchy-shell shell ping
plugins=$(omarchy-shell shell listPlugins)
for plugin in \
omarchy.audio omarchy.background omarchy.bar omarchy.bluetooth \
omarchy.clipboard omarchy.emojis omarchy.launcher omarchy.menu \
omarchy.clipboard omarchy.emojis omarchy.menu \
omarchy.monitor omarchy.network omarchy.notifications omarchy.power \
omarchy.reminders omarchy.weather; do
[[ $plugins == *"$plugin"* ]] || fail "shell plugin is loaded: $plugin" "loaded plugins: $plugins"
+9 -8
View File
@@ -94,23 +94,24 @@ screenshot "success-notification-popup"
omarchy-shell notifications dismissAll >/dev/null
wait_until "notification popup closes" 15 layer_absent "omarchy-notifications"
# The launcher does the full loop: open, search by typing, launch the top hit.
# The menu's Apps submenu does the full launcher loop: open, search by
# typing, launch the top hit.
if window_present "(?i)omawrite" >/dev/null 2>&1; then
fail "launcher test starts with no Omawrite window" "an Omawrite window is already open"
fail "app launch test starts with no Omawrite window" "an Omawrite window is already open"
fi
omarchy-shell shell summon omarchy.launcher >/dev/null
wait_until "launcher opens" 15 layer_present "omarchy-launcher"
omarchy-menu summon apps >/dev/null
wait_until "apps menu opens" 15 layer_present "omarchy-menu"
sleep 1
screenshot "success-launcher-open"
screenshot "success-apps-menu-open"
wtype "omawrite"
sleep 1
screenshot "success-launcher-search"
screenshot "success-apps-menu-search"
wtype -k Return
wait_until "launcher launches the top search hit" 60 window_present "(?i)omawrite"
wait_until "launcher closes after launching" 15 layer_absent "omarchy-launcher"
wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite"
wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu"
close_windows "(?i)omawrite"
wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite"
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const search = requireFromRoot('shell/services/AppSearch.js')
const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8')
const appLibraryQml = fs.readFileSync(path.join(root, 'shell/services/AppLibrary.qml'), 'utf8')
const entries = [
{
name: 'Google Contacts',
genericName: 'Address Book',
comment: 'Manage contacts',
keywords: ['contacts', 'address book', 'people'],
id: 'google-contacts.desktop'
},
{
name: 'Calculator',
genericName: 'Calculator',
comment: 'Perform arithmetic, scientific or financial calculations',
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
id: 'org.gnome.Calculator.desktop'
},
{
name: 'OBS Studio',
genericName: 'Streaming/Recording Software',
comment: 'Free and Open Source Streaming/Recording Software',
keywords: ['streaming', 'recording', 'capture'],
id: 'com.obsproject.Studio.desktop'
},
{
name: 'Aether',
genericName: '',
comment: 'Minimal internet radio player',
keywords: ['audio', 'music', 'radio'],
id: 'io.github.taqi.aether.desktop'
},
{
name: 'Xournal++',
genericName: 'Notetaking',
comment: 'Take handwritten notes',
keywords: ['notes', 'pdf', 'annotation'],
id: 'com.github.xournalpp.xournalpp.desktop'
},
{
name: 'RustDesk',
genericName: 'Remote Desktop',
comment: 'Remote desktop control',
keywords: ['remote', 'desktop', 'control'],
id: 'com.rustdesk.RustDesk.desktop'
}
]
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
assert(
search.fuzzyScore(entries[1], 'contact') < 0,
'calculator does not match contact as a loose subsequence'
)
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
// The menu's Apps submenu is the launcher now: app rows launch and uninstall
// through the shared app library instead of running commands themselves.
const activateMatch = menuQml.match(/function activateIndex\(index, fromPointer\) \{([\s\S]*?)\n \}/)
assert(activateMatch, 'menu activateIndex function exists')
assert(
activateMatch[1].includes('root.appLibrary.launch('),
'menu routes app launch through the shared app library'
)
assert(
!activateMatch[1].includes('entry.execute()'),
'menu does not execute desktop entries directly'
)
const confirmDeleteMatch = menuQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/)
assert(confirmDeleteMatch, 'menu confirmDelete function exists')
assert(
confirmDeleteMatch[1].includes('root.appLibrary.remove('),
'menu delete routes through the shared app library'
)
assert(
confirmDeleteMatch[1].includes('root.cancel()'),
'menu delete closes the menu after confirmation'
)
assert(
/function remove\(desktopId, name\) \{[\s\S]*?omarchy-remove-launcher-entry[\s\S]*?\n \}/.test(appLibraryQml),
'app library remove runs the remover through the shell'
)
assert(
/function launch\(desktopId, name\) \{[\s\S]*?gtk-launch[\s\S]*?\n \}/.test(appLibraryQml) &&
appLibraryQml.includes('Util.execDetached("gtk-launch "'),
'app library runs desktop entry launch through the shell'
)
assert(
/function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(appLibraryQml),
'app library fallback icon index includes device icons'
)
const iconSourceMatch = appLibraryQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/)
assert(iconSourceMatch, 'app library iconSource function exists')
assert(
iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'),
'app library prefers indexed app icons over ambiguous themed icons'
)
const openMatch = menuQml.match(/function openExistingMenu\(initialMenu\) \{([\s\S]*?)\n \}/)
assert(openMatch, 'menu openExistingMenu function exists')
assert(
openMatch[1].includes('root.appLibrary.refreshIcons()'),
'menu refreshes the shared icon index when opened'
)
JS
-145
View File
@@ -1,145 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const search = requireFromRoot('shell/plugins/launcher/LauncherSearch.js')
const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8')
const entries = [
{
name: 'Google Contacts',
genericName: 'Address Book',
comment: 'Manage contacts',
keywords: ['contacts', 'address book', 'people'],
id: 'google-contacts.desktop'
},
{
name: 'Calculator',
genericName: 'Calculator',
comment: 'Perform arithmetic, scientific or financial calculations',
keywords: ['calculation', 'arithmetic', 'scientific', 'financial'],
id: 'org.gnome.Calculator.desktop'
},
{
name: 'OBS Studio',
genericName: 'Streaming/Recording Software',
comment: 'Free and Open Source Streaming/Recording Software',
keywords: ['streaming', 'recording', 'capture'],
id: 'com.obsproject.Studio.desktop'
},
{
name: 'Aether',
genericName: '',
comment: 'Minimal internet radio player',
keywords: ['audio', 'music', 'radio'],
id: 'io.github.taqi.aether.desktop'
},
{
name: 'Xournal++',
genericName: 'Notetaking',
comment: 'Take handwritten notes',
keywords: ['notes', 'pdf', 'annotation'],
id: 'com.github.xournalpp.xournalpp.desktop'
},
{
name: 'RustDesk',
genericName: 'Remote Desktop',
comment: 'Remote desktop control',
keywords: ['remote', 'desktop', 'control'],
id: 'com.rustdesk.RustDesk.desktop'
}
]
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
assert(
search.fuzzyScore(entries[1], 'contact') < 0,
'calculator does not match contact as a loose subsequence'
)
const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry))
assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works')
const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry))
assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works')
assert(
/function select\(delta\)[\s\S]*root\.disarmHover\(\)[\s\S]*root\.selectedIndex =/.test(launcherQml),
'launcher keyboard navigation disarms stale hover before moving selection'
)
assert(
/PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(launcherQml),
'launcher uses shared pointer movement gate in card coordinates'
)
assert(
/function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml),
'launcher resets pointer movement gate when hover is disarmed'
)
const openMatch = launcherQml.match(/function open\(payloadJson\) \{([\s\S]*?)\n \}/)
assert(openMatch, 'launcher open function exists')
assert(
openMatch[1].indexOf('root.disarmHover()') < openMatch[1].indexOf('root.opened = true')
&& !openMatch[1].includes('pointerGate.allowInitialSample()'),
'launcher ignores a stale hidden-pointer position when becoming visible'
)
assert(
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml),
'launcher only selects from pointer after real movement'
)
assert(
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml),
'launcher row hover routes through pointer movement gate'
)
assert(
/onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(launcherQml),
'launcher samples pointer movement immediately when entering a row'
)
assert(
!/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml),
'launcher does not select rows from containsMouse'
)
const confirmDeleteMatch = launcherQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/)
assert(confirmDeleteMatch, 'launcher confirmDelete function exists')
assert(
confirmDeleteMatch[1].includes('root.dismiss()'),
'launcher delete closes launcher after confirmation'
)
assert(
confirmDeleteMatch[1].includes('Util.execDetached(command)'),
'launcher delete runs remover through the shell'
)
const activateMatch = launcherQml.match(/function activateIndex\(index\) \{([\s\S]*?)\n \}/)
assert(activateMatch, 'launcher activateIndex function exists')
assert(
!activateMatch[1].includes('entry.execute()'),
'launcher does not execute desktop entries directly'
)
assert(
activateMatch[1].includes('gtk-launch') && activateMatch[1].includes('Util.execDetached'),
'launcher runs desktop entry launch through the shell'
)
assert(
/function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(launcherQml),
'launcher fallback icon index includes device icons'
)
const iconSourceMatch = launcherQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/)
assert(iconSourceMatch, 'launcher iconSource function exists')
assert(
iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'),
'launcher prefers indexed app icons over ambiguous themed icons'
)
assert(
openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'),
'launcher refreshes its icon index when opened'
)
JS
+2
View File
@@ -92,6 +92,8 @@ assertDeepEqual(
kind: 'action',
icon: '',
iconFont: '',
appIcon: '',
appId: '',
label: 'Theme picker',
target: 'style.theme',
detail: 'Style',
-11
View File
@@ -5,7 +5,6 @@ run_node_test <<'JS'
const fs = require('fs')
const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8')
const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8')
assert(
/rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(menuQml)
@@ -18,14 +17,4 @@ assert(
'Menu row content does not depend on current selected border state'
)
assert(
/rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(launcherQml)
&& /rowReservedBorderRight:\s*Border\.right\(selectedBorderSpec\)/.test(launcherQml),
'Launcher rows reserve selected border insets'
)
assert(
!/anchors\.(left|right)Margin:[^\n]*\brow\.border(Left|Right)\b/.test(launcherQml),
'Launcher row content does not depend on current selected border state'
)
JS
+2 -2
View File
@@ -127,8 +127,8 @@ jq -e '
}
pass "shell IPC returns effective shell config"
[[ $(shell_ipc shell summon omarchy.launcher '{"query":"term"}') == "ok" ]] || fail_with_log "shell IPC summons launcher overlay"
shell_ipc_quiet shell hide omarchy.launcher >/dev/null
[[ $(shell_ipc shell summon omarchy.menu '{"menu":"apps"}') == "ok" ]] || fail_with_log "shell IPC summons menu apps overlay"
shell_ipc_quiet shell hide omarchy.menu >/dev/null
[[ $(shell_ipc shell summon missing.plugin "{}") == "unknown" ]] || fail_with_log "shell IPC rejects unknown plugin"
pass "shell IPC summon and hide contract works"