diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index efdbc351..117deb85 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -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. diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 8a948ec0..b5e02d10 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -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 }) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index a5c55a44..e73a1d44 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -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"}, diff --git a/shell/Commons/Color.qml b/shell/Commons/Color.qml index 466a9158..54163a40 100644 --- a/shell/Commons/Color.qml +++ b/shell/Commons/Color.qml @@ -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) diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 32a78f49..ec61252b 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -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. diff --git a/shell/README.md b/shell/README.md index 704bd71c..6a5f32a0 100644 --- a/shell/README.md +++ b/shell/README.md @@ -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/ diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index 45d8da18..ed4f8c98 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -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 diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 479148db..30ecf81c 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -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` diff --git a/shell/plugins/launcher/Launcher.qml b/shell/plugins/launcher/Launcher.qml deleted file mode 100644 index b38b750d..00000000 --- a/shell/plugins/launcher/Launcher.qml +++ /dev/null @@ -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 - // "" 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 - } - } - } - } - } -} diff --git a/shell/plugins/launcher/manifest.json b/shell/plugins/launcher/manifest.json deleted file mode 100644 index a036bc57..00000000 --- a/shell/plugins/launcher/manifest.json +++ /dev/null @@ -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" - } -} diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 40059445..527a2296 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -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 diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index b03fc981..3eb8236a 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -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 || "", diff --git a/shell/services/AppLibrary.qml b/shell/services/AppLibrary.qml new file mode 100644 index 00000000..35a3a65c --- /dev/null +++ b/shell/services/AppLibrary.qml @@ -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 + // "" 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 + } +} diff --git a/shell/plugins/launcher/LauncherSearch.js b/shell/services/AppSearch.js similarity index 100% rename from shell/plugins/launcher/LauncherSearch.js rename to shell/services/AppSearch.js diff --git a/shell/plugins/launcher/hidden-entries.sh b/shell/services/hidden-entries.sh similarity index 100% rename from shell/plugins/launcher/hidden-entries.sh rename to shell/services/hidden-entries.sh diff --git a/shell/shell.qml b/shell/shell.qml index 5c4a6ce3..0d5c20b0 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -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") diff --git a/test/acceptance.d/session-test.sh b/test/acceptance.d/session-test.sh index 8f87dde3..8d8f53b5 100644 --- a/test/acceptance.d/session-test.sh +++ b/test/acceptance.d/session-test.sh @@ -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" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index fa9c9b03..9e10e85c 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -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" diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh new file mode 100644 index 00000000..1294dd38 --- /dev/null +++ b/test/shell.d/app-search-test.sh @@ -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 diff --git a/test/shell.d/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh deleted file mode 100644 index 97a6449a..00000000 --- a/test/shell.d/launcher-search-test.sh +++ /dev/null @@ -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 diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index df36f109..6cc5239e 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -92,6 +92,8 @@ assertDeepEqual( kind: 'action', icon: '', iconFont: '', + appIcon: '', + appId: '', label: 'Theme picker', target: 'style.theme', detail: 'Style', diff --git a/test/shell.d/row-border-stability-test.sh b/test/shell.d/row-border-stability-test.sh index 35df4c9c..7ba87a8e 100755 --- a/test/shell.d/row-border-stability-test.sh +++ b/test/shell.d/row-border-stability-test.sh @@ -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 diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index c9ed6432..fc4a8c1f 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -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"