import Quickshell import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons import qs.Ui import "MenuModel.js" as MenuModel Item { id: root // 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. property string pendingInitialMenu: "root" function open(payloadJson) { var payload = ({}) try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) } if (payload.fontFamily) root.fontFamily = payload.fontFamily if (payload.mode === "select" || payload.mode === "input") { root.openDmenu(payload) } else { root.openRoute(payload.initialMenu || payload.menu || "root") } } function close() { root.cancel() } function refresh() { defaultMenuFile.reload() userMenuFile.reload() return "ok" } function ping() { return "ok" } property string fontFamily: Style.font.menuFamily // JSONC menu definitions. The shell parses both at startup and merges // the user file on top of the defaults, so the keybind → IPC → visible // path doesn't have to shell out to bash + jq on every open. property string defaultMenuPath: omarchyPath + "/default/omarchy/omarchy-menu.jsonc" property string userMenuPath: Quickshell.env("HOME") + "/.config/omarchy/extensions/omarchy-menu.jsonc" property var defaultMenuItems: [] property var userMenuItems: [] property bool opened: false property string mode: "menu" readonly property bool dmenuActive: mode === "select" || mode === "input" property string dmenuPrompt: "" property var dmenuOptions: [] property string selectionFile: "" property string doneFile: "" property int dmenuWidth: 300 property int dmenuMaxHeight: 0 property bool requestActive: false property bool rowsLoaded: false property string activeMenu: "root" property string filterText: "" property int selectedIndex: 0 property bool cursorActive: false property int requestSerial: 0 property int applySerial: 0 property var items: ({}) property var itemOrder: [] property var navStack: [] 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. property color background: Color.menu.background property color foreground: Color.menu.text property color border: Color.menu.border property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2))) property color scrim: Color.menu.scrim property color selectedBackground: Color.menu.selectedBackground property color selectedText: Color.menu.selectedText property color selectedBorder: Color.menu.selectedBorder property var selectedBorderSpec: Border.surfaceSpec("menu", "selected-border", selectedBorder, 0) readonly property real rowReservedBorderLeft: Border.left(selectedBorderSpec) readonly property real rowReservedBorderRight: Border.right(selectedBorderSpec) readonly property int cornerRadius: Style.cornerRadius property int contentMargin: Style.spacing.panelPadding property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2) property int contentSpacing: Style.spacing.md property int baseRowHeight: Math.max(Style.space(50), Style.font.body + Style.spacing.rowPaddingX * 2) property int detailRowHeight: Math.max(Style.space(58), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2) // How much of the first hidden row stays visible at the fold — enough to // read as a cut-off row rather than a bottom border. property int rowPeek: Math.round(baseRowHeight * 0.55) property int rowSpacing: Style.spacing.xs property int dividerHeight: Style.space(17) property bool searchDivider: false property int layoutSerial: 0 property int cardWidth: Math.min(root.dmenuActive ? Style.space(root.dmenuWidth) : ((root.activeMenu === "trigger.capture.screenrecord" || root.activeMenu === "style.font") ? Style.space(520) : Style.space(300)), panel.width - Style.gapsOut * 2) property int visibleRowsHeight: root.dmenuActive ? dmenuRowListHeight(layoutSerial, displayModel.count, filterText) : rowListHeight(layoutSerial, displayModel.count, filterText, searchDivider) property int cardHeight: root.dmenuActive ? Math.min(contentMargin * 2 + headerHeight + (mode === "input" ? 0 : contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2) : Math.min(contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight, panel.height - Style.gapsOut * 2) function finishRequest(selection) { if (!root.requestActive || !root.doneFile) { root.opened = false return } var activeSelectionFile = root.selectionFile var activeDoneFile = root.doneFile root.requestActive = false root.selectionFile = "" root.doneFile = "" if (selection === null || selection === undefined) { resultProc.command = ["bash", "-c", ": > " + Util.shellQuote(activeDoneFile)] } else { resultProc.command = ["bash", "-c", "printf '%s\\n' " + Util.shellQuote(selection) + " > " + Util.shellQuote(activeSelectionFile) + "; : > " + Util.shellQuote(activeDoneFile)] } resultProc.running = true } function runAction(action) { var command = String(action || "") if (!command) return Util.execDetached(command) } // Menu rows only surface their detail while a search is narrowing them; // dmenu rows carry caller-supplied subtext that must always be visible. function rowHeightForDetail(detail) { return (root.filterText || root.dmenuActive) && detail ? root.detailRowHeight : root.baseRowHeight } // Height the card can devote to rows before running off the screen — or // past the frozen top edge once a search has pinned the card in place. // Uses panel.cardTop rather than effectiveCardTop: the centered top is // derived from the card height, which this value feeds. function availableRowsHeight() { var top = panel.cardTop >= 0 ? panel.cardTop : Style.gapsOut var available = panel.height - top - Style.gapsOut - root.contentMargin * 2 - root.headerHeight - root.contentSpacing // The starting menu sets the ceiling along with the offset: drilling into // a longer submenu scrolls behind the fold instead of growing the card. if (panel.maxRowsHeight >= 0) available = Math.min(available, panel.maxRowsHeight) // A card that swallows the whole screen reads as a page, not a menu. return Math.min(available, Math.round(panel.height * 0.7)) } // When every row fits, the list gets its full height. When they don't, // the card must end mid-row: a clipped row is what tells the eye there is // more below the fold, so never come out even on a row boundary. function foldedListHeight(totals, available) { var count = totals.length if (count === 0) return root.baseRowHeight if (totals[count - 1] <= available) return totals[count - 1] var peek = root.rowPeek var full = 0 while (full < count && totals[full] <= available) full++ while (full > 1 && totals[full - 1] + root.rowSpacing + peek > available) full-- if (full < 1) return Math.max(available, root.baseRowHeight) return totals[full - 1] + root.rowSpacing + peek } function rowListHeight(_serial, _count, _filter, _divider) { if (displayModel.count === 0) return root.baseRowHeight var totals = [] var total = 0 var previousSection = "" for (var i = 0; i < displayModel.count; i++) { var row = displayModel.get(i) if (i > 0) total += root.rowSpacing if (row.section === "drilldown" && previousSection !== "drilldown") total += root.dividerHeight total += root.rowHeightForDetail(row.detail) previousSection = row.section totals.push(total) } return foldedListHeight(totals, availableRowsHeight()) } function dmenuRowListHeight(_serial, _count, _filter) { if (root.mode === "input") return 0 if (displayModel.count === 0) return root.baseRowHeight var available = availableRowsHeight() if (root.dmenuMaxHeight > 0) available = Math.min(available, Style.space(root.dmenuMaxHeight)) var totals = [] var total = 0 for (var i = 0; i < displayModel.count; i++) { if (i > 0) total += root.rowSpacing total += root.rowHeightForDetail(displayModel.get(i).detail) totals.push(total) } return foldedListHeight(totals, available) } function item(id) { return root.items[id] || null } // ------------------------------------------------------------------ // JSONC → normalized item array. Mirrors the bash bin's jq pipeline so // the on-disk authoring format stays untouched. // ------------------------------------------------------------------ function stripJsonc(raw) { return MenuModel.stripJsonc(raw) } function normalizeAliases(value) { return MenuModel.normalizeAliases(value) } function normalizeItem(id, raw) { return MenuModel.normalizeItem(id, raw) } function parseMenuJsonc(raw) { return MenuModel.parseMenuJsonc(raw) } // Merge defaults + user extension. Later entries override earlier ones // on a per-key basis (so the user can tweak label/icon/action without // re-declaring the whole row). function rebuildItemsFromSources() { var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems) root.providerRevision += 1 root.providersLoaded = ({}) root.providerQueue = [] root.items = mergedMenu.items root.itemOrder = mergedMenu.itemOrder root.rowsLoaded = true root.evaluateGuards() if (root.opened) { root.rebuildDisplay() if (!root.dmenuActive) { if (root.filterText.trim()) root.loadProvidersForSearch() else root.loadProviderForMenu(root.activeMenu) } } } // Each known provider is a tiny bash one-liner that enumerates a list and // emits one tab-delimited row per item: `label\tvalue\tcurrent`. The shell // turns those into menu items children of `menuId`. A `volatile` provider // re-runs every time its submenu is entered, so a font installed since the // shell started shows up without restarting it. readonly property var providers: ({ "fonts": { script: "current=$(omarchy-font-current 2>/dev/null); omarchy-font-list 2>/dev/null | while read -r f; do [[ -z $f ]] && continue; printf '%s\\t%s\\t%s\\n' \"$f\" \"$f\" \"$current\"; done", icon: "", volatile: true, actionFor: function(value) { return "omarchy-font-set " + Util.shellQuote(value) } }, "power-profiles": { script: "current=$(powerprofilesctl get 2>/dev/null); omarchy-powerprofiles-list 2>/dev/null | while read -r p; do [[ -z $p ]] && continue; printf '%s\\t%s\\t%s\\n' \"$p\" \"$p\" \"$current\"; done", icon: "\udb81\udc0b", actionFor: function(value) { return "omarchy-powerprofiles-set autodetect " + Util.shellQuote(value) } } }) function slugify(value) { 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 rows = root.appLibrary.sortedEntries("") var appRows = [] for (var j = 0; j < rows.length; j++) { var entry = rows[j].entry var appId = String(entry.id || "") if (!appId) continue 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) { } appRows.push({ id: "apps." + appId, 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: 0 }) } var merged = MenuModel.mergeAppRows(root.items, root.itemOrder, appRows) root.items = merged.items root.itemOrder = merged.itemOrder 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 root.providersLoaded[id] = true providerProc.menuId = id providerProc.providerKey = entry.provider providerProc.revision = root.providerRevision providerProc.collected = "" providerProc.command = ["bash", "-lc", spec.script] providerProc.running = true } function mergeProviderRows(rows, menuId, providerKey) { var spec = root.providers[providerKey] if (!spec) return var lines = String(rows || "").split("\n") var providerRows = [] var takenIds = ({}) for (var i = 0; i < lines.length; i++) { var line = lines[i].trim() if (!line) continue var parts = line.split("\t") var label = parts[0] || "" var value = parts[1] || parts[0] || "" var current = parts[2] || "" if (!label) continue // Distinct values can slugify alike — Fira Code and Fira-Code both give // fira-code — and a repeated id is dropped, which would silently lose a // row from the list. Nudge it until it is the row's own. var rowId = menuId + "." + root.slugify(value) while (takenIds[rowId]) rowId += "-" takenIds[rowId] = true providerRows.push({ id: rowId, parent: menuId, kind: "action", icon: (value === current) ? "✓" : (spec.icon || ""), label: label, title: "", target: "", description: "", action: spec.actionFor(value), provider: "", aliases: [], when: "", checked: "", order: 0 }) } var merged = MenuModel.swapProviderRows(root.items, root.itemOrder, menuId, providerRows) root.items = merged.items root.itemOrder = merged.itemOrder if (root.opened) root.rebuildDisplay() } function startNextProvider() { if (providerProc.running) return while (root.providerQueue.length > 0) { var id = root.providerQueue.shift() var entry = root.item(id) if (!entry || !entry.provider || root.providersLoaded[id]) continue root.startProviderForMenu(id) return } } // Entering a submenu is the one moment a volatile list is worth paying for // again: it may have been reshaped by the last pick from it. Search doesn't // invalidate, or every keystroke would restart the same enumeration. function invalidateVolatileProvider(id) { var entry = root.item(id) var spec = entry && entry.provider ? root.providers[entry.provider] : null if (spec && spec.volatile) root.providersLoaded[id] = false } function loadProviderForMenu(id) { 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 } root.startProviderForMenu(id) } function loadProvidersForSearch() { var active = root.item(root.activeMenu) ? root.activeMenu : "root" for (var i = 0; i < root.itemOrder.length; i++) { var entry = root.item(root.itemOrder[i]) if (!entry || !entry.provider || root.providersLoaded[entry.id]) continue if (active !== "root" && entry.id !== active && !root.isDescendantOf(entry.id, active)) continue root.loadProviderForMenu(entry.id) } } function depthFor(id) { return MenuModel.depthFor(root.items, id) } function pathFor(id) { return MenuModel.pathFor(root.items, id) } function parentPathFor(id) { return MenuModel.parentPathFor(root.items, id) } function isDescendantOf(id, ancestorId) { return MenuModel.isDescendantOf(root.items, id, ancestorId) } function childCount(id) { return MenuModel.childCount(root.items, root.itemOrder, id) } // Guarded items are hidden when their `when:` evaluates false. Static // submenus are also hidden when none of their descendants are visible; // provider-backed menus stay visible because their rows load on demand. function isVisible(entry) { return MenuModel.isVisible(root.items, root.itemOrder, root.whenResults, entry) } // Label with the ✓ marker baked in when `checked:` evaluated truthy. function labelFor(entry) { return MenuModel.labelFor(entry, root.checkedResults) } function searchableToken(value) { return MenuModel.searchableToken(value) } function leafIdFor(id) { return MenuModel.leafIdFor(id) } function nameSearchText(entry) { return MenuModel.nameSearchText(entry) } function termInSearchWords(term, text) { return MenuModel.termInSearchWords(term, text) } function descriptionTextMatches(query, text) { return MenuModel.descriptionTextMatches(query, text) } function matchesQuery(entry, query) { return MenuModel.matchesQuery(entry, query, root.isVisible(entry)) } function searchScore(entry, query) { return MenuModel.searchScore(root.items, entry, query) } function displayRow(entry, detail, score, section) { return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section) } function rebuildDmenuDisplay() { displayModel.clear() root.searchDivider = false if (root.mode === "input") { layoutSerial += 1 return } var query = root.filterText.trim().toLowerCase() for (var i = 0; i < root.dmenuOptions.length; i++) { // An option is "