From 2bf4f4a9fbbc233f2f4af5bdf8a253cc7b58462c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 21 Jul 2026 16:30:36 -0700 Subject: [PATCH] Hide menu submenus when none of their children are visible Static submenus and links now inherit visibility from their descendants, so hardware sections like Touchpad Haptics disappear on machines without the hardware instead of opening empty. Laptop Display and Mirror Display are guarded by omarchy-hw-laptop. The guard evaluator brace-wraps every when/checked condition before silencing it, so conditions in the jsonc no longer need their own >/dev/null plumbing, and parents no longer need to repeat their children's guards. Co-Authored-By: Claude Fable 5 --- default/omarchy/omarchy-menu.jsonc | 35 +++++++++++++++--------------- shell/plugins/menu/Menu.qml | 14 +++++------- shell/plugins/menu/MenuModel.js | 20 +++++++++++++++++ test/shell.d/menu-test.sh | 24 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index e220043f..c02313d8 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -14,13 +14,13 @@ // checked shell condition; append ✓ when it succeeds // Root Menu - "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"keywords":"","action":"omarchy-shell shell summon omarchy.launcher"}, + "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"action":"omarchy-shell shell summon omarchy.launcher"}, "learn": {"icon":"󰧑","label":"Learn","keywords":"manual docs help"}, - "trigger": {"icon":"󱓞","label":"Trigger","keywords":""}, - "style": {"icon":"","label":"Style","keywords":""}, - "setup": {"icon":"","label":"Setup","aliases":["settings"],"keywords":""}, - "install": {"icon":"󰉉","label":"Install","keywords":""}, - "remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"],"keywords":""}, + "trigger": {"icon":"󱓞","label":"Trigger"}, + "style": {"icon":"","label":"Style"}, + "setup": {"icon":"","label":"Setup","aliases":["settings"]}, + "install": {"icon":"󰉉","label":"Install"}, + "remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]}, "update": {"icon":"","label":"Update","aliases":["restart","refresh"],"keywords":"upgrade channel firmware timezone"}, "about": {"icon":"","label":"About","keywords":"omarchy system information","action":"omarchy-launch-about"}, "system": {"icon":"","label":"System","aliases":["power-menu"],"keywords":"lock logout reboot shutdown suspend hibernate"}, @@ -47,7 +47,7 @@ "trigger.reminder": {"icon":"󰢌","label":"Reminder","aliases":["reminder"],"keywords":"timer notification remind"}, "trigger.capture": {"icon":"","label":"Capture","aliases":["capture","screenshot","screenrecord","screen-record","screenrecording"],"keywords":"color text extraction"}, "trigger.capture.screenshot": {"icon":"","label":"Screenshot","keywords":"picture","action":"omarchy-capture-screenshot"}, - "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","keywords":"recording video","when":"pgrep -f '^gpu-screen-recorder' >/dev/null","action":"omarchy-capture-screenrecording --stop-recording"}, + "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","keywords":"recording video","when":"pgrep -f '^gpu-screen-recorder'","action":"omarchy-capture-screenrecording --stop-recording"}, "trigger.capture.screenrecord": {"icon":"","label":"Screenrecord","keywords":"record screen video"}, "trigger.capture.text": {"icon":"󰴑","label":"Text","keywords":"ocr text extraction","action":"omarchy-capture-text"}, "trigger.capture.color": {"icon":"󰃉","label":"Color","keywords":"picker hyprpicker","action":"pkill hyprpicker || hyprpicker -a"}, @@ -59,15 +59,15 @@ "trigger.share": {"icon":"","label":"Share","aliases":["share"],"keywords":"clipboard file folder localsend"}, "trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"],"keywords":"screensaver nightlight idle notifications bar quickshell"}, "trigger.hardware": {"icon":"","label":"Hardware","aliases":["hardware","hw"],"keywords":"monitor touchpad touchscreen gpu"}, - "trigger.hardware.laptop-display": {"icon":"󰛧","label":"Laptop Display","keywords":"monitor","action":"omarchy-hyprland-monitor-internal toggle"}, - "trigger.hardware.mirror-display": {"icon":"󰍹","label":"Mirror Display","keywords":"monitor","action":"omarchy-hyprland-monitor-internal-mirror toggle"}, - "trigger.hardware.hybrid-gpu": {"icon":"","label":"Hybrid GPU","keywords":"graphics","when":"omarchy-hw-hybrid-gpu >/dev/null","action":"omarchy-launch-floating-terminal-with-presentation omarchy-toggle-hybrid-gpu"}, - "trigger.hardware.touchpad": {"icon":"󰟸","label":"Touchpad","keywords":"trackpad","when":"omarchy-hw-touchpad >/dev/null","action":"omarchy-toggle-touchpad"}, - "trigger.hardware.touchpad-haptics": {"icon":"󰌌","label":"Touchpad Haptics","when":"omarchy-hw-dell-xps-haptic-touchpad >/dev/null && omarchy-cmd-present dell-xps-touchpad-haptics"}, - "trigger.hardware.touchpad-haptics.low": {"icon":"󰌌","label":"low","when":"omarchy-hw-dell-xps-haptic-touchpad >/dev/null && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"low\" ]]","action":"dell-xps-touchpad-haptics set low"}, - "trigger.hardware.touchpad-haptics.mid": {"icon":"󰌌","label":"mid","keywords":"medium","when":"omarchy-hw-dell-xps-haptic-touchpad >/dev/null && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"mid\" ]]","action":"dell-xps-touchpad-haptics set mid"}, - "trigger.hardware.touchpad-haptics.high": {"icon":"󰌌","label":"high","when":"omarchy-hw-dell-xps-haptic-touchpad >/dev/null && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"high\" ]]","action":"dell-xps-touchpad-haptics set high"}, - "trigger.hardware.touchscreen": {"icon":"󰆽","label":"Touchscreen","keywords":"tablet","when":"omarchy-hw-touchscreen >/dev/null","action":"omarchy-toggle-touchscreen"}, + "trigger.hardware.laptop-display": {"icon":"󰛧","label":"Laptop Display","keywords":"monitor","when":"omarchy-hw-laptop","action":"omarchy-hyprland-monitor-internal toggle"}, + "trigger.hardware.mirror-display": {"icon":"󰍹","label":"Mirror Display","keywords":"monitor","when":"omarchy-hw-laptop","action":"omarchy-hyprland-monitor-internal-mirror toggle"}, + "trigger.hardware.hybrid-gpu": {"icon":"","label":"Hybrid GPU","keywords":"graphics","when":"omarchy-hw-hybrid-gpu","action":"omarchy-launch-floating-terminal-with-presentation omarchy-toggle-hybrid-gpu"}, + "trigger.hardware.touchpad": {"icon":"󰟸","label":"Touchpad","keywords":"trackpad","when":"omarchy-hw-touchpad","action":"omarchy-toggle-touchpad"}, + "trigger.hardware.touchpad-haptics": {"icon":"󰌌","label":"Touchpad Haptics"}, + "trigger.hardware.touchpad-haptics.low": {"icon":"󰌌","label":"low","when":"omarchy-hw-dell-xps-haptic-touchpad && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"low\" ]]","action":"dell-xps-touchpad-haptics set low"}, + "trigger.hardware.touchpad-haptics.mid": {"icon":"󰌌","label":"mid","keywords":"medium","when":"omarchy-hw-dell-xps-haptic-touchpad && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"mid\" ]]","action":"dell-xps-touchpad-haptics set mid"}, + "trigger.hardware.touchpad-haptics.high": {"icon":"󰌌","label":"high","when":"omarchy-hw-dell-xps-haptic-touchpad && omarchy-cmd-present dell-xps-touchpad-haptics","checked":"[[ \"$(dell-xps-touchpad-haptics get)\" == \"high\" ]]","action":"dell-xps-touchpad-haptics set high"}, + "trigger.hardware.touchscreen": {"icon":"󰆽","label":"Touchscreen","keywords":"tablet","when":"omarchy-hw-touchscreen","action":"omarchy-toggle-touchscreen"}, "trigger.reminder.set": {"icon":"󰢌","label":"Set one","aliases":["reminder-set","remind"],"keywords":"timer","action":"omarchy-reminder -i"}, "trigger.reminder.show": {"icon":"󰢌","label":"Show all","keywords":"list","action":"omarchy-reminder show"}, "trigger.reminder.clear": {"icon":"󰢌","label":"Clear all","keywords":"delete","action":"omarchy-reminder clear"}, @@ -83,6 +83,7 @@ "trigger.toggle.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","action":"omarchy-hyprland-workspace-layout-toggle"}, "trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"}, "trigger.toggle.one-window-ratio": {"icon":"","label":"1-Window Ratio","keywords":"square","action":"omarchy-hyprland-window-single-square-aspect-toggle"}, + // Style "style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"keywords":"colors","action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""}, "style.background": {"icon":"","label":"Background","aliases":["background","wallpaper"],"action":"background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\""}, @@ -236,7 +237,7 @@ "remove.browser": {"icon":"","label":"Browser","keywords":"uninstall"}, "remove.dictation": {"icon":"","label":"Dictation","keywords":"uninstall voice","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-remove"}, "remove.gaming": {"icon":"","label":"Gaming","keywords":"uninstall steam retroarch minecraft"}, - "remove.service": {"icon":"","label":"Services","keywords":"uninstall dropbox tailscale vpn","when":"omarchy-pkg-present dropbox || omarchy-pkg-present tailscale"}, + "remove.service": {"icon":"","label":"Services","keywords":"uninstall dropbox tailscale vpn"}, "remove.windows": {"icon":"󰍲","label":"Windows","keywords":"uninstall vm","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"}, "remove.preinstalls": {"icon":"󰏓","label":"Preinstalls","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"}, "remove.security": {"icon":"","label":"Security","keywords":"fingerprint fido2 ssh sshd"}, diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 6f4bda7e..603aa4d5 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -347,13 +347,11 @@ Item { return MenuModel.childCount(root.items, root.itemOrder, id) } - // Items whose `when:` evaluated to false are hidden everywhere — nav, - // drilldown, and search. Items with no `when:` are always visible. + // 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) { - if (!entry) return false - if (!entry.when) return true - var result = root.whenResults[entry.id] - return result === undefined ? true : result + return MenuModel.isVisible(root.items, root.itemOrder, root.whenResults, entry) } // Label with the ✓ marker baked in when `checked:` evaluated truthy. @@ -752,8 +750,8 @@ Item { 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 (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 = ({}) diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index f3805108..251ee4bc 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -173,6 +173,25 @@ function childCount(items, itemOrder, id) { return count } +function isVisible(items, itemOrder, whenResults, entry, depth) { + if (!entry) return false + if (entry.when && whenResults && whenResults[entry.id] === false) return false + if (entry.kind !== "menu" && entry.kind !== "link") return true + if (entry.provider) return true + + var guard = depth || 0 + if (guard >= 32) return false + + var target = entry.kind === "link" ? entry.target : entry.id + var order = Array.isArray(itemOrder) ? itemOrder : [] + for (var i = 0; i < order.length; i++) { + var child = item(items, order[i]) + if (child && child.parent === target && isVisible(items, itemOrder, whenResults, child, guard + 1)) return true + } + + return false +} + function labelFor(entry, checkedResults) { if (!entry) return "" if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓" @@ -282,6 +301,7 @@ if (typeof module !== "undefined") { parentPathFor: parentPathFor, isDescendantOf: isDescendantOf, childCount: childCount, + isVisible: isVisible, labelFor: labelFor, searchableToken: searchableToken, leafIdFor: leafIdFor, diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index cf86d6b2..fcd0f838 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -64,6 +64,20 @@ assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects assertEqual(menu.childCount(merged.items, merged.itemOrder, 'style'), 1, 'menu counts children') assertEqual(menu.labelFor({ id: 'style.theme', label: 'Theme', checked: 'cmd' }, { 'style.theme': true }), 'Theme ✓', 'menu appends checked marker') +const visibilityItems = { + hardware: menu.normalizeItem('hardware', { label: 'Hardware' }), + laptop: menu.normalizeItem('hardware.laptop', { label: 'Laptop', when: 'is-laptop', action: 'toggle-laptop' }), + nested: menu.normalizeItem('nested', { label: 'Nested' }), + branch: menu.normalizeItem('nested.branch', { label: 'Branch' }), + leaf: menu.normalizeItem('nested.branch.leaf', { label: 'Leaf', when: 'has-leaf', action: 'run-leaf' }), + dynamic: menu.normalizeItem('dynamic', { label: 'Dynamic', provider: 'items' }) +} +const visibilityOrder = Object.keys(visibilityItems) +assert(!menu.isVisible(visibilityItems, visibilityOrder, { 'hardware.laptop': false }, visibilityItems.hardware), 'menu hides a submenu with no visible children') +assert(menu.isVisible(visibilityItems, visibilityOrder, { 'hardware.laptop': true }, visibilityItems.hardware), 'menu shows a submenu with a visible child') +assert(!menu.isVisible(visibilityItems, visibilityOrder, { 'nested.branch.leaf': false }, visibilityItems.nested), 'menu hides recursively empty submenus') +assert(menu.isVisible(visibilityItems, visibilityOrder, {}, visibilityItems.dynamic), 'menu keeps provider-backed submenus visible') + const entry = merged.items['style.theme'] assert(menu.matchesQuery(entry, 'theme', true), 'menu matches labels and aliases') assert(menu.matchesQuery(entry, 'colors', true), 'menu matches aliases') @@ -122,6 +136,16 @@ assert( !defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'], 'menu removes the relocated toggles from Trigger > Toggle' ) +assertEqual( + defaultById['trigger.hardware.laptop-display'].when, + 'omarchy-hw-laptop', + 'menu only shows Laptop Display on laptops' +) +assertEqual( + defaultById['trigger.hardware.mirror-display'].when, + 'omarchy-hw-laptop', + 'menu only shows Mirror Display on laptops' +) assert( /font\.family: row\.iconFont\.length > 0 \? row\.iconFont : root\.fontFamily/.test(menuQml), 'menu rows support per-icon font families'