Files
arthur-os/default/quickshell/omarchy-shell/plugins/menu/Menu.qml
T
Ryan Hughes a960d2b1bf Move omarchy-menu data and dispatch into the shell
omarchy-menu used to spawn an 821-line bash bin on every keybind: 11 jq
invocations against omarchy-menu.jsonc, mktemp dance, summon, poll. The
shell waited ~235ms (keybind to visible) before painting the first row.

The bar plugin host is already a persistent process with the menu plugin
loaded (keepLoaded: true) — it just wasn't allowed to own the menu data.
Now it does:

  - parseMenuJsonc / normalizeItem ports the bash bin's jq pipeline to
    JS: strip // comments + trailing commas, infer parent from dotted
    ids, dedupe keyword tokens across id/aliases/keywords, fill defaults.
  - Two FileViews with watchChanges: true on the default and user
    extension JSONCs. Edit either file and the in-memory item map
    refreshes without a restart.
  - IpcHandler exposes menu.summon(initialMenu) / refresh() / close() /
    ping(). summon resolves aliases the way the old bash route_target
    did, including the leaf-alias-is-an-action shortcut.
  - Action dispatch is now direct: when summoned via IPC (no selection
    or done file), Quickshell.execDetached(['bash','-lc', action]) runs
    the action and the menu closes. The legacy tempfile dance is kept
    only for the duration of the migration window.
  - provider sub-menus (fonts, power-profiles) are inline: tab-delimited
    bash one-liner per provider, parsed by mergeProviderRows.
  - when:/checked: bash expressions are batched into one Process at
    shell startup (and again whenever the JSONCs change). The menu
    opens instantly; visibility/check marks settle within ~50ms.

Measured: keybind-to-visible via the new IPC path is ~30ms (was ~235ms).
The bash bin still works for now and is removed in the next commit.
2026-05-14 17:39:04 -04:00

1276 lines
42 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
Item {
id: root
// Injected by omarchy-shell when this plugin is summoned.
property string omarchyPath: Quickshell.env("OMARCHY_PATH") || (Quickshell.env("HOME") + "/.local/share/omarchy")
property var shell: null
property var manifest: null
property var pluginRegistry: null
// Plugin lifecycle hooks. The host calls open(payloadJson) after
// `omarchy-shell-ipc shell summon omarchy.menu ...` and close() when hidden.
property string menuJsonFile: ""
property string pendingInitialMenu: "root"
property string pendingSelectionFile: ""
property string pendingDoneFile: ""
property string pendingColorsRaw: ""
function decodeBase64(value) {
var s = String(value || "")
if (!s) return ""
try { return Qt.atob(s) } catch (e) { return s }
}
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.pendingInitialMenu = payload.initialMenu || payload.menu || "root"
root.pendingSelectionFile = payload.selectionFile || ""
root.pendingDoneFile = payload.doneFile || ""
root.pendingColorsRaw = payload.colorsRawBase64 ? root.decodeBase64(payload.colorsRawBase64) : (payload.colorsRaw || "")
if (payload.fontFamily) root.fontFamily = payload.fontFamily
var menuJson = payload.menuJsonBase64 ? root.decodeBase64(payload.menuJsonBase64) : (payload.menuJson || "")
if (menuJson) {
root.openMenu(menuJson, root.pendingInitialMenu, root.pendingSelectionFile, root.pendingDoneFile, root.pendingColorsRaw)
return
}
root.menuJsonFile = payload.menuJsonFile || ""
if (root.menuJsonFile) { menuJsonFileView.reload(); return }
// Hot path: no file, no payload — use the cached items the shell loaded
// from JSONC at startup. This is what direct IPC summons land on.
root.openExistingMenu(root.pendingInitialMenu, root.pendingSelectionFile, root.pendingDoneFile)
}
function close() {
root.closeMenu("")
}
property string menuBin: omarchyPath + "/bin/omarchy-menu"
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property string colorsFile: Quickshell.env("OMARCHY_MENU_COLORS_FILE") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/colors.toml")
property string styleFile: Quickshell.env("OMARCHY_MENU_STYLE_FILE") || (Quickshell.env("HOME") + "/.local/state/omarchy/toggles/quickshell-menu.json")
// 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 string selectionFile: ""
property string doneFile: ""
property bool requestActive: false
property bool opened: false
property bool rowsLoaded: false
property string activeMenu: "root"
property string filterText: ""
property int selectedIndex: 0
property int requestSerial: 0
property int applySerial: 0
property var items: ({})
property var itemOrder: []
property var navStack: []
property var providersLoaded: ({})
property var providerQueue: []
property var doneFilesToRelease: []
property color accent: "#89b4fa"
property color background: "#101315"
property color foreground: "#cacccc"
property color border: foreground
property int cornerRadius: 0
property int contentMargin: 18
property int headerHeight: 34
property int contentSpacing: 6
property int baseRowHeight: 50
property int detailRowHeight: 58
property int rowSpacing: 3
property int dividerHeight: 17
property bool searchDivider: false
property int layoutSerial: 0
property int cardWidth: Math.min(300, panel.width - 48)
property int visibleRowsHeight: rowListHeight(layoutSerial, displayModel.count, filterText, searchDivider)
property int cardHeight: Math.min(Math.max(220, contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight), panel.height - 48)
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function decodeField(value) {
return String(value || "").replace(/\v/g, "\n").replace(/\f/g, "\t")
}
function withAlpha(color, alpha) {
return Qt.rgba(color.r, color.g, color.b, alpha)
}
function rowHeightForDetail(detail) {
return root.filterText && detail ? root.detailRowHeight : root.baseRowHeight
}
function rowListHeight(_serial, _count, _filter, _divider) {
if (displayModel.count === 0) return root.baseRowHeight
var count = Math.min(displayModel.count, 10)
var total = 0
var previousSection = ""
for (var i = 0; i < 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
}
return total
}
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) {
// Match what the bash bin's jq pipeline accepts: full-line `//` comments
// plus trailing commas before } or ]. Anything fancier should land in
// valid JSON anyway.
return String(raw || "")
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
.replace(/,(\s*[}\]])/g, "$1")
}
function normalizeAliases(value) {
if (Array.isArray(value)) return value.filter(function(v) { return v })
if (typeof value === "string" && value) return [value]
return []
}
function normalizeKeywords(id, aliases, raw) {
function space(s) { return String(s || "").replace(/[._-]+/g, " ") }
var parts = [space(id), aliases.map(space).join(" "), raw || ""].join(" ").split(/\s+/)
var seen = {}
var out = []
for (var i = 0; i < parts.length; i++) {
var p = parts[i]
if (!p || seen[p]) continue
seen[p] = true
out.push(p)
}
return out.join(" ")
}
function normalizeItem(id, raw) {
var aliases = normalizeAliases(raw.aliases)
var parent = raw.parent
if (parent === undefined) {
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
}
if (id === "root") parent = ""
var kind = raw.action ? "action" : (raw.target ? "link" : "menu")
return {
id: id,
parent: parent,
kind: kind,
icon: raw.icon || "",
label: raw.label || id,
target: raw.target || "",
keywords: normalizeKeywords(id, aliases, raw.keywords),
description: raw.description || "",
action: raw.action || "",
provider: raw.provider || "",
aliases: aliases,
when: raw.when || "",
checked: raw.checked || ""
}
}
function parseMenuJsonc(raw) {
var stripped = stripJsonc(raw)
if (!stripped.trim()) return []
var parsed
try { parsed = JSON.parse(stripped) } catch (e) {
console.warn("omarchy-menu: JSONC parse failed:", e)
return []
}
if (typeof parsed !== "object" || parsed === null) return []
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
? parsed.items
: parsed
var out = []
for (var id in source) {
var entry = source[id]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
out.push(normalizeItem(id, entry))
}
return out
}
// 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 nextItems = ({})
var nextOrder = []
var sources = [root.defaultMenuItems || [], root.userMenuItems || []]
for (var s = 0; s < sources.length; s++) {
var src = sources[s]
for (var i = 0; i < src.length; i++) {
var entry = src[i]
if (!entry || !entry.id) continue
if (!nextItems[entry.id]) nextOrder.push(entry.id)
var prior = nextItems[entry.id] || {}
var merged = {}
for (var k in prior) merged[k] = prior[k]
for (var k2 in entry) merged[k2] = entry[k2]
merged.id = entry.id
nextItems[entry.id] = merged
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
root.items = nextItems
root.itemOrder = nextOrder
root.rowsLoaded = true
root.evaluateGuards()
if (root.opened) root.rebuildDisplay()
}
// Legacy: invoked by the bash bin via the menuJsonFile payload. The new
// in-shell path uses rebuildItemsFromSources instead; this branch stays
// for the transitional period while the bin still exists.
function loadMenuJson(raw) {
var nextItems = ({})
var nextOrder = []
var payload = JSON.parse(raw || "{}")
var rows = payload.items || []
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
var id = entry.id || ""
if (!id) continue
var order = nextItems[id] ? nextItems[id].order : nextOrder.length
if (!nextItems[id]) nextOrder.push(id)
nextItems[id] = {
id: id,
parent: entry.parent || "",
kind: entry.kind || "action",
icon: entry.icon || "",
label: entry.label || id,
target: entry.target || "",
keywords: entry.keywords || "",
description: entry.description || "",
action: entry.action || "",
provider: entry.provider || "",
order: order
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", order: -1 }
}
root.items = nextItems
root.itemOrder = nextOrder
root.rowsLoaded = true
}
function mergeProviderJson(raw, menuId) {
var payload = ({})
try {
payload = JSON.parse(raw || "{}")
} catch (e) {
return
}
var rows = payload.items || []
var changed = false
for (var i = 0; i < rows.length; i++) {
var entry = rows[i]
var id = entry.id || ""
if (!id) continue
if (!root.items[id]) root.itemOrder.push(id)
root.items[id] = {
id: id,
parent: entry.parent || menuId,
kind: entry.kind || "action",
icon: entry.icon || "",
label: entry.label || id,
target: entry.target || "",
keywords: entry.keywords || "",
description: entry.description || "",
action: entry.action || "",
provider: entry.provider || "",
order: root.itemOrder.indexOf(id)
}
changed = true
}
if (changed) root.rebuildDisplay()
}
// 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`.
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: "",
actionFor: function(value) { return "omarchy-font-set '" + value.replace(/'/g, "'\\''") + "'" },
keywordsFor: function(value) { return value + " typeface" }
},
"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 "powerprofilesctl set '" + value.replace(/'/g, "'\\''") + "'" },
keywordsFor: function(value) { return value + " power profile" }
}
})
function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
}
function startProviderForMenu(id) {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[id]) return
var spec = root.providers[entry.provider]
if (!spec) return
root.providersLoaded[id] = true
providerProc.menuId = id
providerProc.providerKey = entry.provider
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 changed = false
var lines = String(rows || "").split("\n")
var nextOrder = root.itemOrder.slice()
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
var id = menuId + "." + root.slugify(value)
var displayLabel = (value === current) ? (label + " ✓") : label
if (!root.items[id]) nextOrder.push(id)
root.items[id] = {
id: id,
parent: menuId,
kind: "action",
icon: spec.icon || "",
label: displayLabel,
target: "",
keywords: spec.keywordsFor(value),
description: "",
action: spec.actionFor(value),
provider: "",
aliases: [],
when: "",
checked: "",
order: nextOrder.indexOf(id)
}
changed = true
}
root.itemOrder = nextOrder
if (changed && 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
}
}
function loadProviderForMenu(id) {
var entry = root.item(id)
if (!entry || !entry.provider || root.providersLoaded[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) {
var depth = 0
var current = root.item(id)
var guard = 0
while (current && current.parent && current.parent !== "root" && guard < 32) {
depth += 1
current = root.item(current.parent)
guard += 1
}
return depth
}
function pathFor(id) {
var labels = []
var current = root.item(id)
var guard = 0
while (current && current.id !== "root" && guard < 32) {
labels.unshift(current.label)
current = root.item(current.parent)
guard += 1
}
return labels.join(" ")
}
function parentPathFor(id) {
var entry = root.item(id)
if (!entry || !entry.parent || entry.parent === "root") return ""
return root.pathFor(entry.parent)
}
function breadcrumbFor(id) {
var path = root.pathFor(id)
return path ? ("Go " + path) : "Go"
}
function isDescendantOf(id, ancestorId) {
if (ancestorId === "root") return id !== "root"
var current = root.item(id)
var guard = 0
while (current && current.parent && guard < 32) {
if (current.parent === ancestorId) return true
current = root.item(current.parent)
guard += 1
}
return false
}
function childCount(id) {
var count = 0
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.item(root.itemOrder[i])
if (entry && entry.parent === id) count += 1
}
return count
}
// Items whose `when:` evaluated to false are hidden everywhere — nav,
// drilldown, and search. Items with no `when:` are always visible.
function isVisible(entry) {
if (!entry) return false
if (!entry.when) return true
var result = root.whenResults[entry.id]
return result === undefined ? true : result
}
// Label with the ✓ marker baked in when `checked:` evaluated truthy.
function labelFor(entry) {
if (!entry) return ""
if (entry.checked && root.checkedResults[entry.id]) return entry.label + " ✓"
return entry.label
}
function matchesQuery(entry, query) {
if (!entry || entry.id === "root") return false
if (!root.isVisible(entry)) return false
var haystack = (entry.label + " " + root.pathFor(entry.id) + " " + entry.keywords + " " + entry.description).toLowerCase()
var terms = query.toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (terms[i] && haystack.indexOf(terms[i]) === -1) return false
}
return true
}
function searchScore(entry, query) {
var needle = query.toLowerCase().trim()
var label = entry.label.toLowerCase()
var path = root.pathFor(entry.id).toLowerCase()
var keywords = entry.keywords.toLowerCase()
var parent = root.item(entry.parent)
var parentLabel = parent ? parent.label.toLowerCase() : ""
var score = 80
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (label.indexOf(needle) === 0) score = 10
else if (path.indexOf(needle) === 0) score = entry.kind === "action" ? 12 : 14
else if (parentLabel === needle) score = entry.kind === "action" ? 18 : 20
else if (label.indexOf(needle) >= 0) score = 30
else if (path.indexOf(needle) >= 0) score = 40
else if (keywords.indexOf(needle) >= 0) score = 50
if (entry.kind === "menu" || entry.kind === "link") score -= 2
return score * 1000 + root.depthFor(entry.id) * 25 + entry.order
}
function displayRow(entry, detail, score, section) {
var target = entry.kind === "link" ? entry.target : entry.id
return {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
label: root.labelFor(entry),
target: target,
detail: detail || "",
path: root.pathFor(entry.id),
childCount: (entry.kind === "menu" || entry.kind === "link") ? root.childCount(target) : 0,
action: entry.action || "",
provider: entry.provider || "",
score: score || 0,
section: section || ""
}
}
function rebuildDisplay() {
displayModel.clear()
if (!root.rowsLoaded) return
var active = root.item(root.activeMenu) ? root.activeMenu : "root"
root.activeMenu = active
var rows = []
var query = root.filterText.trim()
root.searchDivider = false
if (query) {
var currentRows = []
var drilldownRows = []
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.item(root.itemOrder[i])
if (!entry || entry.id === "root") continue
if (!root.isDescendantOf(entry.id, active)) continue
if (!root.matchesQuery(entry, query)) continue
var detail = root.parentPathFor(entry.id)
var row = root.displayRow(entry, detail, root.searchScore(entry, query))
if (entry.parent === active) currentRows.push(row)
else drilldownRows.push(row)
}
var searchSort = function(a, b) {
if (a.score !== b.score) return a.score - b.score
return a.path.localeCompare(b.path)
}
currentRows.sort(searchSort)
drilldownRows.sort(searchSort)
root.searchDivider = currentRows.length > 0 && drilldownRows.length > 0
if (root.searchDivider) {
for (var d = 0; d < drilldownRows.length; d++) drilldownRows[d].section = "drilldown"
}
rows = currentRows.concat(drilldownRows)
} else {
if (active !== "root") {
var parentTarget = root.item(active).parent || "root"
var backTarget = root.navStack.length > 0 ? root.navStack[root.navStack.length - 1] : parentTarget
rows.push({ itemId: "__back", kind: "back", icon: "", label: "Back", target: backTarget, detail: root.breadcrumbFor(backTarget), path: "", childCount: 0, action: "", score: -1, section: "" })
}
for (var j = 0; j < root.itemOrder.length; j++) {
var child = root.item(root.itemOrder[j])
if (!child || child.parent !== active) continue
if (!root.isVisible(child)) continue
rows.push(root.displayRow(child, child.description, child.order))
}
}
for (var k = 0; k < rows.length; k++) displayModel.append(rows[k])
layoutSerial += 1
if (displayModel.count === 0) selectedIndex = 0
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
else if (selectedIndex < 0) selectedIndex = 0
Qt.callLater(function() {
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function select(delta) {
if (displayModel.count === 0) return
selectedIndex += delta
if (selectedIndex < 0) selectedIndex = 0
if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
}
function setFilter(nextFilter) {
root.filterText = nextFilter
root.selectedIndex = 0
if (root.filterText.trim()) root.loadProvidersForSearch()
root.rebuildDisplay()
}
function setActiveMenu(id, pushHistory) {
if (!root.item(id)) id = "root"
if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu])
root.activeMenu = id
root.filterText = ""
root.selectedIndex = 0
root.rebuildDisplay()
root.loadProviderForMenu(id)
}
function goBack() {
if (root.activeMenu === "root") return false
if (root.navStack.length > 0) {
var previous = root.navStack[root.navStack.length - 1]
root.navStack = root.navStack.slice(0, root.navStack.length - 1)
root.setActiveMenu(previous, false)
return true
}
var active = root.item(root.activeMenu)
root.setActiveMenu((active && active.parent) ? active.parent : "root", false)
return true
}
function activateIndex(index) {
if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index)
if (row.kind === "back") {
root.goBack()
} else if (row.kind === "menu" || row.kind === "link") {
root.setActiveMenu(row.target || row.itemId, true)
} else {
root.applySelected(row.itemId, row.action)
}
}
function releaseNextDoneFile() {
if (releaseProc.running || doneFilesToRelease.length === 0) return
var path = doneFilesToRelease.shift()
releaseProc.command = ["bash", "-lc", ": > " + shellQuote(path)]
releaseProc.running = true
}
function finishDoneFile(path) {
if (!path) return
doneFilesToRelease.push(path)
releaseNextDoneFile()
}
function resetRequest() {
requestActive = false
selectionFile = ""
doneFile = ""
}
function applySelected(id, action) {
if (!id) { cancel(); return }
// Direct-IPC path: no bash bin was involved, so no tempfiles to write.
// Execute the action ourselves and close the menu.
if (!selectionFile || !doneFile) {
applySerial = requestSerial
resetRequest()
opened = false
filterText = ""
if (action) Quickshell.execDetached(["bash", "-lc", action])
return
}
// Legacy bash-bin path: write selection + done file, the bash watcher
// picks it up and runs the action. Stays for the transitional period
// while the old bin can still summon the menu via menuJsonFile.
var activeSelectionFile = selectionFile
var activeDoneFile = doneFile
applySerial = requestSerial
resetRequest()
opened = false
applyProc.command = ["bash", "-lc", "printf '%s\\t%s\\n' " + shellQuote(id) + " " + shellQuote(action || "") + " > " + shellQuote(activeSelectionFile) + "; : > " + shellQuote(activeDoneFile)]
applyProc.running = true
}
function cancel() {
if (requestActive) finishDoneFile(doneFile)
resetRequest()
opened = false
filterText = ""
}
function closeMenu(nextDoneFile) {
requestSerial += 1
if (requestActive) finishDoneFile(doneFile)
if (nextDoneFile && nextDoneFile !== doneFile) finishDoneFile(nextDoneFile)
resetRequest()
opened = false
filterText = ""
}
function openExistingMenu(initialMenu, nextSelectionFile, nextDoneFile) {
requestSerial += 1
if (requestActive && doneFile && doneFile !== nextDoneFile) finishDoneFile(doneFile)
selectionFile = nextSelectionFile
doneFile = nextDoneFile
requestActive = !!doneFile
activeMenu = root.item(initialMenu) ? initialMenu : "root"
navStack = []
filterText = ""
selectedIndex = 0
opened = true
rebuildDisplay()
loadProviderForMenu(activeMenu)
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function openMenu(menuJson, initialMenu, nextSelectionFile, nextDoneFile, colorsRaw) {
loadMenuJson(menuJson)
if (colorsRaw) loadColors(colorsRaw)
providersLoaded = ({})
providerQueue = []
openExistingMenu(initialMenu, nextSelectionFile, nextDoneFile)
}
function loadColors(raw) {
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
if (!match) continue
if (match[1] === "foreground") foreground = match[2]
else if (match[1] === "background") background = match[2]
else if (match[1] === "color4" || match[1] === "accent") accent = match[2]
}
border = foreground
}
function loadStyle(raw) {
try {
var style = JSON.parse(raw || "{}")
root.cornerRadius = Number(style.radius || 0)
} catch (e) {}
}
ListModel { id: displayModel }
// ----------------------------------------------------------- IPC surface
//
// `omarchy-shell-ipc menu summon <id>` is the keybind hot path — the
// Hyprland bindings call straight into here, no bash hops. `refresh` is
// a manual nudge for when watchChanges isn't enough (e.g. someone wired
// a CI step that re-emits the JSONC).
// Mirrors `route_target` in the old bash bin: caller may pass a real id
// (`system`, `setup.power`) or an alias declared in JSONC (`power`,
// `reminder-set`). Unknown strings fall through to the id-as-route
// behavior so misspellings still attempt to open the literal id.
function resolveRoute(input) {
var raw = String(input || "").toLowerCase().replace(/_/g, "-")
if (!raw || raw === "go" || raw === "menu") return "root"
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.items[root.itemOrder[i]]
if (!entry || !entry.aliases) continue
for (var j = 0; j < entry.aliases.length; j++) {
var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-")
if (alias === raw) return entry.id
}
}
return raw
}
IpcHandler {
target: "menu"
function summon(initialMenu: string): string {
var id = root.resolveRoute(initialMenu)
var entry = root.items[id]
// If the resolved id is an action (i.e. the user invoked an alias for
// a leaf, e.g. `omarchy-shell-ipc menu summon screenrecord-stop`),
// run it directly instead of opening an action with no children.
if (entry && entry.kind === "action" && entry.action) {
Quickshell.execDetached(["bash", "-lc", entry.action])
return "ok"
}
// If it's a link (a redirect to another menu), follow the link.
if (entry && entry.kind === "link" && entry.target) id = entry.target
var payload = JSON.stringify({ menu: id })
root.open(payload)
return "ok"
}
function refresh(): string {
defaultMenuFile.reload()
userMenuFile.reload()
return "ok"
}
function close(): string {
root.close()
return "ok"
}
function ping(): string { return "ok" }
}
Process {
id: providerProc
property string menuId: ""
property string providerKey: ""
property string collected: ""
stdout: SplitParser {
onRead: function(data) { providerProc.collected += data + "\n" }
}
onExited: {
root.mergeProviderRows(providerProc.collected, providerProc.menuId, providerProc.providerKey)
if (root.filterText.trim()) root.loadProvidersForSearch()
root.startNextProvider()
}
}
FileView {
id: menuJsonFileView
path: root.menuJsonFile
printErrors: false
onLoaded: root.openMenu(text(), root.pendingInitialMenu, root.pendingSelectionFile, root.pendingDoneFile, root.pendingColorsRaw)
}
// 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.
FileView {
id: defaultMenuFile
path: root.defaultMenuPath
watchChanges: true
printErrors: false
onLoaded: { root.defaultMenuItems = root.parseMenuJsonc(text()); root.rebuildItemsFromSources() }
onFileChanged: reload()
}
FileView {
id: userMenuFile
path: root.userMenuPath
watchChanges: true
printErrors: false
onLoaded: { root.userMenuItems = root.parseMenuJsonc(text()); root.rebuildItemsFromSources() }
onLoadFailed: { root.userMenuItems = []; root.rebuildItemsFromSources() }
onFileChanged: reload()
}
// ---------------------------------------------------------------- guards
//
// `when:` (visibility) and `checked:` (✓ marker) are bash expressions the
// shell wasn't allowed to evaluate before the perf rewrite. Now the shell
// batches them into one bash subprocess per (re)load so the open path
// never has to wait on them.
property var whenResults: ({}) // id → true|false (allow visibility)
property var checkedResults: ({}) // id → true|false (show ✓)
function evaluateGuards() {
var script = ""
var ids = Object.keys(root.items)
for (var i = 0; i < ids.length; i++) {
var entry = root.items[ids[i]]
if (!entry) continue
if (entry.when) script += "if " + entry.when + " >/dev/null 2>&1; then echo " + ids[i] + ":w:1; else echo " + ids[i] + ":w:0; fi\n"
if (entry.checked) script += "if " + entry.checked + " >/dev/null 2>&1; then echo " + ids[i] + ":c:1; else echo " + ids[i] + ":c:0; fi\n"
}
if (!script) {
root.whenResults = ({})
root.checkedResults = ({})
return
}
guardProc.collected = ""
guardProc.command = ["bash", "-lc", script]
guardProc.running = true
}
Process {
id: guardProc
property string collected: ""
stdout: SplitParser {
onRead: function(data) { guardProc.collected += data + "\n" }
}
onExited: {
var nextWhen = ({})
var nextChecked = ({})
var lines = guardProc.collected.split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var colon = line.lastIndexOf(":")
if (colon < 0) continue
var value = line.substring(colon + 1) === "1"
var rest = line.substring(0, colon)
var tagAt = rest.lastIndexOf(":")
if (tagAt < 0) continue
var id = rest.substring(0, tagAt)
var tag = rest.substring(tagAt + 1)
if (tag === "w") nextWhen[id] = value
else if (tag === "c") nextChecked[id] = value
}
root.whenResults = nextWhen
root.checkedResults = nextChecked
if (root.opened) root.rebuildDisplay()
}
}
FileView {
path: root.colorsFile
watchChanges: true
onLoaded: root.loadColors(text())
onFileChanged: { reload(); root.loadColors(text()) }
}
FileView {
path: root.styleFile
watchChanges: true
onLoaded: root.loadStyle(text())
onFileChanged: { reload(); root.loadStyle(text()) }
}
Process {
id: applyProc
onExited: {
if (root.applySerial === root.requestSerial) root.opened = false
}
}
Process {
id: releaseProc
onExited: root.releaseNextDoneFile()
}
PanelWindow {
id: panel
visible: root.opened && root.rowsLoaded
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "omarchy-menu"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
MouseArea {
anchors.fill: parent
onClicked: root.cancel()
}
Rectangle {
id: card
width: root.cardWidth
height: root.cardHeight
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
border.color: root.border
border.width: 2
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else root.cancel()
event.accepted = true
} else if (event.key === Qt.Key_Backspace) {
if (root.filterText.length > 0) root.setFilter(root.filterText.slice(0, -1))
else root.goBack()
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_Left) {
if (!root.filterText) root.goBack()
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Right) {
root.activateIndex(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
}
}
}
Column {
anchors.fill: parent
anchors.margins: root.contentMargin
spacing: root.contentSpacing
Rectangle {
width: parent.width
height: root.headerHeight
radius: root.cornerRadius
color: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.filterText || ((root.item(root.activeMenu) ? root.item(root.activeMenu).label : "Go") + "…")
color: root.foreground
opacity: root.filterText ? 1 : 0.58
font.family: root.fontFamily
font.pixelSize: 16
elide: Text.ElideRight
}
}
Item {
width: parent.width
height: root.visibleRowsHeight
ListView {
id: resultList
anchors.fill: parent
model: displayModel
clip: true
spacing: root.rowSpacing
boundsBehavior: Flickable.StopAtBounds
section.property: "section"
section.criteria: ViewSection.FullString
section.delegate: Item {
required property string section
width: ListView.view.width
height: section === "drilldown" ? root.dividerHeight : 0
visible: section === "drilldown"
Rectangle {
anchors.left: parent.left
anchors.leftMargin: 4
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
height: 1
color: root.withAlpha(root.foreground, 0.2)
}
}
delegate: Rectangle {
id: row
required property int index
required property string itemId
required property string kind
required property string icon
required property string label
required property string target
required property string detail
required property string path
required property string action
required property int childCount
width: ListView.view.width
height: root.rowHeightForDetail(row.detail)
radius: root.cornerRadius
color: index === root.selectedIndex ? root.withAlpha(root.foreground, 0.08) : root.withAlpha(root.foreground, mouseArea.containsMouse ? 0.045 : 0)
border.color: "transparent"
border.width: 0
Rectangle {
visible: false
width: 4
height: parent.height - 18
radius: Math.min(root.cornerRadius, 4)
color: root.accent
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
}
Text {
id: iconText
text: row.icon
color: index === root.selectedIndex ? root.accent : root.foreground
opacity: row.kind === "back" ? 0.7 : 1
font.family: root.fontFamily
font.pixelSize: 18
width: 36
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.left: parent.left
anchors.leftMargin: 8
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
}
Column {
id: contentColumn
anchors.left: iconText.right
anchors.leftMargin: 6
anchors.right: trail.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
id: labelText
width: parent.width
text: row.label
color: index === root.selectedIndex ? root.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: 16
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.detail
visible: root.filterText && row.detail.length > 0
color: root.foreground
opacity: 0.52
font.family: root.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
}
}
Row {
id: trail
width: 14
anchors.right: parent.right
anchors.rightMargin: 8
y: contentColumn.y + labelText.y + (labelText.height - height) / 2
spacing: 0
Text {
visible: false
text: row.childCount
color: root.foreground
opacity: 0.45
font.family: root.fontFamily
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: row.kind === "menu" || row.kind === "link" ? "" : (row.kind === "back" ? "" : "↵")
color: index === root.selectedIndex ? root.accent : root.foreground
opacity: row.kind === "back" ? 0 : 0.36
font.family: root.fontFamily
font.pixelSize: 16
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activateIndex(row.index)
}
}
}
Column {
anchors.centerIn: parent
spacing: 8
visible: displayModel.count === 0
Text {
text: "󰈉"
color: root.accent
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: 28
horizontalAlignment: Text.AlignHCenter
width: 320
}
Text {
text: root.filterText ? "No matches for “" + root.filterText + "”" : "Nothing here yet"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
width: 320
}
}
}
Item {
width: parent.width
height: 0
}
}
}
}
}