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.
This commit is contained in:
Ryan Hughes
2026-05-14 17:39:04 -04:00
parent f470dcc27a
commit a960d2b1bf
@@ -30,7 +30,7 @@ Item {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.pendingInitialMenu = payload.initialMenu || "root"
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 || "")
@@ -43,7 +43,11 @@ Item {
}
root.menuJsonFile = payload.menuJsonFile || ""
if (root.menuJsonFile) menuJsonFileView.reload()
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() {
@@ -54,6 +58,13 @@ Item {
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
@@ -126,6 +137,124 @@ Item {
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 = []
@@ -200,17 +329,81 @@ Item {
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.output = ""
providerProc.command = [root.menuBin, "--provider", entry.provider, 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
@@ -312,8 +505,25 @@ Item {
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+/)
@@ -353,7 +563,7 @@ Item {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
label: entry.label,
label: root.labelFor(entry),
target: target,
detail: detail || "",
path: root.pathFor(entry.id),
@@ -414,6 +624,7 @@ Item {
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))
}
}
@@ -505,11 +716,22 @@ Item {
}
function applySelected(id, action) {
if (!id || !selectionFile || !doneFile) {
cancel()
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
@@ -586,17 +808,75 @@ Item {
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 output: ""
property string providerKey: ""
property string collected: ""
stdout: SplitParser {
onRead: function(data) {
providerProc.output += data + "\n"
}
onRead: function(data) { providerProc.collected += data + "\n" }
}
onExited: {
root.mergeProviderJson(output, menuId)
root.mergeProviderRows(providerProc.collected, providerProc.menuId, providerProc.providerKey)
if (root.filterText.trim()) root.loadProvidersForSearch()
root.startNextProvider()
}
@@ -609,6 +889,87 @@ Item {
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