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:
@@ -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,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 })
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -92,6 +92,8 @@ assertDeepEqual(
|
||||
kind: 'action',
|
||||
icon: '',
|
||||
iconFont: '',
|
||||
appIcon: '',
|
||||
appId: '',
|
||||
label: 'Theme picker',
|
||||
target: 'style.theme',
|
||||
detail: 'Style',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user