From 4b93f8d84d3e10bbcd787891341bc6df14555bea Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 15 Aug 2026 15:31:07 +0200 Subject: [PATCH] Dim installed software in the Install menus instead of hiding it (#6955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install rows hid themselves with `when:"! "`, so software you already had vanished from the very list it was installed from. Add a `disabled:` guard that keeps a row listed but dim, ✓-marked, unselectable and out of search, and move every Install row onto it. Co-authored-by: Claude Fable 5 --- default/omarchy/omarchy-menu.jsonc | 124 +++++++++++++++-------------- shell/plugins/menu/Menu.qml | 85 ++++++++++++++++---- shell/plugins/menu/MenuModel.js | 36 ++++++--- test/shell.d/menu-guards-test.sh | 9 ++- test/shell.d/menu-test.sh | 92 ++++++++++++++++++++- 5 files changed, 255 insertions(+), 91 deletions(-) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 372a7025..6978038c 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -14,6 +14,10 @@ // title header text shown when the submenu is open; defaults to label // when shell condition; hide row when it fails // checked shell condition; append ✓ when it succeeds + // disabled shell condition; when it succeeds the row stays listed but + // goes dim, takes a ✓ and can no longer be selected. Install + // rows use it so software already on the machine reads as + // installed instead of vanishing from the list. // Root Menu "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps"}, @@ -201,71 +205,71 @@ "install.browser": {"icon":"","label":"Browser"}, "install.ai": {"icon":"󱚤","label":"AI"}, "install.gaming": {"icon":"","label":"Gaming"}, - "install.windows": {"icon":"󰍲","label":"Windows","when":"[[ ! -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, - "install.preinstalls": {"icon":"󰏓","label":"Preinstalls","when":"[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"}, - "install.browser.chrome": {"icon":"","label":"Chrome","when":"! omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"}, - "install.browser.edge": {"icon":"󰇩","label":"Edge","when":"! omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, - "install.browser.brave": {"icon":"","label":"Brave","when":"! omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave'"}, - "install.browser.brave-origin": {"icon":"","label":"Brave Origin","when":"! omarchy-pkg-present brave-origin-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave-origin'"}, - "install.browser.firefox": {"icon":"","label":"Firefox","when":"! omarchy-pkg-present firefox","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser firefox'"}, - "install.browser.zen": {"icon":"󰖟","label":"Zen","when":"! omarchy-pkg-present zen-browser-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser zen'"}, - "install.service.1password": {"icon":"󰢁","label":"1Password","when":"! omarchy-pkg-present 1password","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password"}, - "install.service.dropbox": {"icon":"","label":"Dropbox","when":"! omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, - "install.service.spotify": {"icon":"󰓇","label":"Spotify","when":"! omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, - "install.service.signal": {"icon":"󰭹","label":"Signal","when":"! omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"}, - "install.service.tailscale": {"icon":"","label":"Tailscale","when":"! omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"}, - "install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","when":"! omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"}, - "install.service.once": {"icon":"󰏖","label":"ONCE","when":"! omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"}, - "install.service.bitwarden": {"icon":"󰟵","label":"Bitwarden","when":"! omarchy-pkg-present bitwarden","action":"omarchy-install-and-launch Bitwarden 'bitwarden bitwarden-cli' bitwarden"}, - "install.service.chromium-account": {"icon":"","label":"Chromium Account","when":"[[ -f ~/.config/chromium-flags.conf ]] && ! grep -q oauth2-client-id ~/.config/chromium-flags.conf","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-chromium-google-account"}, - "install.editor.vscode": {"icon":"","label":"VSCode","when":"! omarchy-pkg-present visual-studio-code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-vscode"}, - "install.editor.cursor": {"icon":"","label":"Cursor","when":"! omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, - "install.editor.zed": {"icon":"","label":"Zed","when":"! omarchy-pkg-present zed","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-zed"}, - "install.editor.sublime": {"icon":"","label":"Sublime Text","when":"! omarchy-pkg-present sublime-text-4","action":"omarchy-install-and-launch 'Sublime Text' sublime-text-4 sublime_text"}, - "install.editor.helix": {"icon":"","label":"Helix","when":"! omarchy-pkg-present helix","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-helix"}, - "install.editor.vim": {"icon":"","label":"Vim","when":"! omarchy-pkg-present vim","action":"omarchy-install-app Vim vim"}, - "install.editor.emacs": {"icon":"","label":"Emacs","when":"! omarchy-pkg-present omarchy-emacs","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-emacs"}, - "install.terminal.alacritty": {"icon":"","label":"Alacritty","when":"! omarchy-pkg-present alacritty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal alacritty'"}, - "install.terminal.foot": {"icon":"","label":"Foot","when":"! omarchy-pkg-present foot","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal foot'"}, - "install.terminal.ghostty": {"icon":"","label":"Ghostty","when":"! omarchy-pkg-present ghostty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal ghostty'"}, - "install.terminal.kitty": {"icon":"","label":"Kitty","when":"! omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, - "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","when":"! omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, - "install.ai.dictation": {"icon":"","label":"Dictation","when":"! omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, - "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"! omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, - "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"! omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, - "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"! omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, - "install.gaming.steam": {"icon":"","label":"Steam","when":"! omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, - "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","when":"! omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, - "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"! omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, - "install.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","when":"! flatpak info com.nvidia.geforcenow","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-geforce-now"}, - "install.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","when":"[[ ! -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-cloud"}, - "install.gaming.xbox-controllers": {"icon":"󰂯","label":"Xbox Controllers","when":"! omarchy-pkg-present xpadneo-dkms","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-controllers"}, - "install.gaming.battlenet": {"icon":"","label":"Battle.net","when":"[[ ! -d $HOME/Games/battlenet ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-battlenet"}, - "install.gaming.lutris": {"icon":"","label":"Lutris","when":"! omarchy-pkg-present lutris","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-lutris"}, - "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","when":"! omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, + "install.windows": {"icon":"󰍲","label":"Windows","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, + "install.preinstalls": {"icon":"󰏓","label":"Preinstalls","disabled":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"}, + "install.browser.chrome": {"icon":"","label":"Chrome","disabled":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"}, + "install.browser.edge": {"icon":"󰇩","label":"Edge","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, + "install.browser.brave": {"icon":"","label":"Brave","disabled":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave'"}, + "install.browser.brave-origin": {"icon":"","label":"Brave Origin","disabled":"omarchy-pkg-present brave-origin-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave-origin'"}, + "install.browser.firefox": {"icon":"","label":"Firefox","disabled":"omarchy-pkg-present firefox","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser firefox'"}, + "install.browser.zen": {"icon":"󰖟","label":"Zen","disabled":"omarchy-pkg-present zen-browser-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser zen'"}, + "install.service.1password": {"icon":"󰢁","label":"1Password","disabled":"omarchy-pkg-present 1password","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password"}, + "install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, + "install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, + "install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"}, + "install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"}, + "install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"}, + "install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"}, + "install.service.bitwarden": {"icon":"󰟵","label":"Bitwarden","disabled":"omarchy-pkg-present bitwarden","action":"omarchy-install-and-launch Bitwarden 'bitwarden bitwarden-cli' bitwarden"}, + "install.service.chromium-account": {"icon":"","label":"Chromium Account","when":"[[ -f ~/.config/chromium-flags.conf ]]","disabled":"grep -q oauth2-client-id ~/.config/chromium-flags.conf","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-chromium-google-account"}, + "install.editor.vscode": {"icon":"","label":"VSCode","disabled":"omarchy-pkg-present visual-studio-code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-vscode"}, + "install.editor.cursor": {"icon":"","label":"Cursor","disabled":"omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, + "install.editor.zed": {"icon":"","label":"Zed","disabled":"omarchy-pkg-present zed","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-zed"}, + "install.editor.sublime": {"icon":"","label":"Sublime Text","disabled":"omarchy-pkg-present sublime-text-4","action":"omarchy-install-and-launch 'Sublime Text' sublime-text-4 sublime_text"}, + "install.editor.helix": {"icon":"","label":"Helix","disabled":"omarchy-pkg-present helix","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-helix"}, + "install.editor.vim": {"icon":"","label":"Vim","disabled":"omarchy-pkg-present vim","action":"omarchy-install-app Vim vim"}, + "install.editor.emacs": {"icon":"","label":"Emacs","disabled":"omarchy-pkg-present omarchy-emacs","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-emacs"}, + "install.terminal.alacritty": {"icon":"","label":"Alacritty","disabled":"omarchy-pkg-present alacritty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal alacritty'"}, + "install.terminal.foot": {"icon":"","label":"Foot","disabled":"omarchy-pkg-present foot","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal foot'"}, + "install.terminal.ghostty": {"icon":"","label":"Ghostty","disabled":"omarchy-pkg-present ghostty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal ghostty'"}, + "install.terminal.kitty": {"icon":"","label":"Kitty","disabled":"omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, + "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, + "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, + "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, + "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, + "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, + "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, + "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, + "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, + "install.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","disabled":"flatpak info com.nvidia.geforcenow","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-geforce-now"}, + "install.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","disabled":"[[ -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-cloud"}, + "install.gaming.xbox-controllers": {"icon":"󰂯","label":"Xbox Controllers","disabled":"omarchy-pkg-present xpadneo-dkms","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-controllers"}, + "install.gaming.battlenet": {"icon":"","label":"Battle.net","disabled":"[[ -d $HOME/Games/battlenet ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-battlenet"}, + "install.gaming.lutris": {"icon":"","label":"Lutris","disabled":"omarchy-pkg-present lutris","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-lutris"}, + "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","disabled":"omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, "install.gaming.retro-launcher": {"icon":"󰯉","label":"RetroArch Game Launcher","action":"omarchy-games-retro-install"}, - "install.development.rails": {"icon":"󰫏","label":"Ruby on Rails","when":"[[ ! -d $HOME/.local/share/mise/installs/ruby ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ruby'"}, + "install.development.rails": {"icon":"󰫏","label":"Ruby on Rails","disabled":"[[ -d $HOME/.local/share/mise/installs/ruby ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ruby'"}, "install.development.docker-dbs": {"icon":"","label":"Docker DB","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-docker-dbs"}, "install.development.javascript": {"icon":"","label":"JavaScript"}, - "install.development.go": {"icon":"","label":"Go","when":"[[ ! -d $HOME/.local/share/mise/installs/go ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env go'"}, + "install.development.go": {"icon":"","label":"Go","disabled":"[[ -d $HOME/.local/share/mise/installs/go ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env go'"}, "install.development.php": {"icon":"","label":"PHP"}, - "install.development.python": {"icon":"","label":"Python","when":"[[ ! -d $HOME/.local/share/mise/installs/python ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env python'"}, + "install.development.python": {"icon":"","label":"Python","disabled":"[[ -d $HOME/.local/share/mise/installs/python ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env python'"}, "install.development.elixir": {"icon":"","label":"Elixir"}, - "install.development.zig": {"icon":"","label":"Zig","when":"[[ ! -d $HOME/.local/share/mise/installs/zig ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env zig'"}, - "install.development.rust": {"icon":"","label":"Rust","when":"[[ ! -d $HOME/.rustup ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env rust'"}, - "install.development.java": {"icon":"","label":"Java","when":"[[ ! -d $HOME/.local/share/mise/installs/java ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env java'"}, - "install.development.dotnet": {"icon":"","label":".NET","when":"[[ ! -d $HOME/.local/share/mise/installs/dotnet ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env dotnet'"}, - "install.development.ocaml": {"icon":"","label":"OCaml","when":"[[ ! -d $HOME/.opam ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ocaml'"}, - "install.development.clojure": {"icon":"","label":"Clojure","when":"[[ ! -d $HOME/.local/share/mise/installs/clojure ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env clojure'"}, - "install.development.scala": {"icon":"","label":"Scala","when":"[[ ! -d $HOME/.local/share/mise/installs/scala ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env scala'"}, - "install.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ ! -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, - "install.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ ! -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, - "install.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ ! -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, - "install.development.php.php": {"icon":"","label":"PHP","when":"! omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, - "install.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ ! -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, - "install.development.php.symfony": {"icon":"","label":"Symfony","when":"! omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, - "install.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ ! -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, - "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"! compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, + "install.development.zig": {"icon":"","label":"Zig","disabled":"[[ -d $HOME/.local/share/mise/installs/zig ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env zig'"}, + "install.development.rust": {"icon":"","label":"Rust","disabled":"[[ -d $HOME/.rustup ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env rust'"}, + "install.development.java": {"icon":"","label":"Java","disabled":"[[ -d $HOME/.local/share/mise/installs/java ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env java'"}, + "install.development.dotnet": {"icon":"","label":".NET","disabled":"[[ -d $HOME/.local/share/mise/installs/dotnet ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env dotnet'"}, + "install.development.ocaml": {"icon":"","label":"OCaml","disabled":"[[ -d $HOME/.opam ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ocaml'"}, + "install.development.clojure": {"icon":"","label":"Clojure","disabled":"[[ -d $HOME/.local/share/mise/installs/clojure ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env clojure'"}, + "install.development.scala": {"icon":"","label":"Scala","disabled":"[[ -d $HOME/.local/share/mise/installs/scala ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env scala'"}, + "install.development.javascript.node": {"icon":"","label":"Node.js","disabled":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, + "install.development.javascript.bun": {"icon":"","label":"Bun","disabled":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, + "install.development.javascript.deno": {"icon":"","label":"Deno","disabled":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, + "install.development.php.php": {"icon":"","label":"PHP","disabled":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, + "install.development.php.symfony": {"icon":"","label":"Symfony","disabled":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, + "install.development.elixir.elixir": {"icon":"","label":"Elixir","disabled":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, + "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","disabled":"compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, // Remove "remove.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-remove"}, diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 044d4bbc..eeaf2e25 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -317,6 +317,7 @@ Item { aliases: aliases, when: "", checked: "", + disabled: "", order: 0 }) } @@ -382,6 +383,7 @@ Item { aliases: [], when: "", checked: "", + disabled: "", order: 0 }) } @@ -470,9 +472,10 @@ Item { return MenuModel.isVisible(root.items, root.itemOrder, root.whenResults, entry) } - // Label with the ✓ marker baked in when `checked:` evaluated truthy. + // Label with the ✓ marker baked in when `checked:` or `disabled:` evaluated + // truthy. function labelFor(entry) { - return MenuModel.labelFor(entry, root.checkedResults) + return MenuModel.labelFor(entry, root.checkedResults, root.disabledResults) } function searchableToken(value) { @@ -495,8 +498,17 @@ Item { return MenuModel.descriptionTextMatches(query, text) } + // Rows whose `disabled:` evaluated truthy stay listed but dimmed, and the + // cursor steps over them. + function isDisabled(entry) { + return MenuModel.isDisabled(root.disabledResults, entry) + } + + // A disabled row earns its place in the submenu it belongs to, where the + // list around it is the point. Search is a list of what you can do, so it + // leaves them out. function matchesQuery(entry, query) { - return MenuModel.matchesQuery(entry, query, root.isVisible(entry)) + return MenuModel.matchesQuery(entry, query, root.isVisible(entry) && !root.isDisabled(entry)) } function searchScore(entry, query) { @@ -504,7 +516,38 @@ Item { } function displayRow(entry, detail, score, section) { - return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section) + return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, root.disabledResults, entry, detail, score, section) + } + + function rowSelectable(index) { + if (index < 0 || index >= displayModel.count) return false + return !displayModel.get(index).disabled + } + + // First selectable row at or past `from`, continuing in the direction of + // travel and wrapping. -1 when every row is disabled, which leaves the menu + // with no cursor at all rather than one parked on a row Enter won't run. + function nextSelectable(from, direction) { + var count = displayModel.count + if (count === 0) return -1 + + var step = direction < 0 ? -1 : 1 + var index = ((from % count) + count) % count + for (var i = 0; i < count; i++) { + if (root.rowSelectable(index)) return index + index = (index + step + count) % count + } + + return -1 + } + + // Park the cursor on a selectable row after the rows underneath it changed. + // A menu with nothing selectable in it -- every app in it already installed + // -- shows no cursor at all, and grows one the moment a row can take it. + function settleCursor() { + var target = root.nextSelectable(root.selectedIndex, 1) + root.selectedIndex = target >= 0 ? target : 0 + root.cursorActive = target >= 0 } function rebuildDmenuDisplay() { @@ -530,6 +573,7 @@ Item { && detail.toLowerCase().indexOf(query) < 0) continue displayModel.append({ itemId: "dmenu." + i, + disabled: false, kind: "dmenu", icon: icon, iconFont: "", @@ -630,9 +674,7 @@ Item { 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 + root.settleCursor() Qt.callLater(function() { if (displayModel.count > 0) root.revealCursor() @@ -665,12 +707,12 @@ Item { if (displayModel.count === 0) return root.disarmPointer() - if (!cursorActive) { - cursorActive = true - selectedIndex = delta < 0 ? displayModel.count - 1 : 0 - } else { - selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count - } + var from = cursorActive ? selectedIndex + delta : (delta < 0 ? displayModel.count - 1 : 0) + var target = root.nextSelectable(from, delta) + if (target < 0) return + + cursorActive = true + selectedIndex = target revealCursor() } @@ -727,7 +769,7 @@ Item { return } - if (index < 0 || index >= displayModel.count) return + if (!root.rowSelectable(index)) return var row = displayModel.get(index) if (row.kind === "menu" || row.kind === "link") { @@ -874,6 +916,7 @@ Item { function selectFromPointer(index, item, mouse) { if (!pointerGate.moved(item, mouse)) return + if (!root.rowSelectable(index)) return root.cursorActive = true root.selectedIndex = index } @@ -947,6 +990,7 @@ Item { property var whenResults: ({}) // id → true|false (allow visibility) property var checkedResults: ({}) // id → true|false (show ✓) + property var disabledResults: ({}) // id → true|false (dim, skip cursor) property bool guardsPending: false function evaluateGuards() { @@ -966,6 +1010,7 @@ Item { if (!script) { root.whenResults = ({}) root.checkedResults = ({}) + root.disabledResults = ({}) return } guardProc.collected = "" @@ -991,6 +1036,7 @@ Item { var nextWhen = ({}) var nextChecked = ({}) + var nextDisabled = ({}) var lines = guardProc.collected.split("\n") for (var i = 0; i < lines.length; i++) { var line = lines[i].trim() @@ -1005,9 +1051,11 @@ Item { var tag = rest.substring(tagAt + 1) if (tag === "w") nextWhen[id] = value else if (tag === "c") nextChecked[id] = value + else if (tag === "d") nextDisabled[id] = value } root.whenResults = nextWhen root.checkedResults = nextChecked + root.disabledResults = nextDisabled if (root.opened) root.rebuildDisplay() // Run the evaluation that had to stand aside. Deferred by a turn so the // process is settled before its command is set again. @@ -1108,7 +1156,7 @@ Item { if (root.mode === "input") root.applyDmenuSelection(root.filterText) else if (displayModel.count > 0) root.activateIndex(root.cursorActive ? root.selectedIndex : 0) } else if (root.cursorActive) root.activateIndex(root.selectedIndex) - else if (displayModel.count > 0) root.cursorActive = true + else root.settleCursor() 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) @@ -1211,6 +1259,7 @@ Item { required property string path required property string action required property int childCount + required property bool disabled readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex readonly property bool isApp: row.kind === "app" @@ -1218,6 +1267,9 @@ Item { width: ListView.view.width height: root.rowHeightForDetail(row.detail) + // Faded: the row is here to say the software is already + // installed, not to be picked. + opacity: row.disabled ? 0.4 : 1 radius: root.cornerRadius color: row.hasCursor ? root.selectedBackground : "transparent" borderSpec: row.hasCursor ? root.selectedBorderSpec : Border.none() @@ -1330,7 +1382,7 @@ Item { id: mouseArea anchors.fill: parent hoverEnabled: true - cursorShape: Qt.PointingHandCursor + cursorShape: row.disabled ? Qt.ArrowCursor : Qt.PointingHandCursor onEntered: root.selectFromPointer(row.index, row, { x: mouseArea.mouseX, y: mouseArea.mouseY @@ -1339,6 +1391,7 @@ Item { root.selectFromPointer(row.index, row, mouse) } onClicked: { + if (row.disabled) return root.cursorActive = true root.selectedIndex = row.index root.activateIndex(row.index, true) diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index c6dc7ed3..28c995ea 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -34,7 +34,8 @@ function normalizeItem(id, raw) { provider: value.provider || "", aliases: aliases, when: value.when || "", - checked: value.checked || "" + checked: value.checked || "", + disabled: value.disabled || "" } } @@ -83,7 +84,7 @@ function mergeMenuSources(defaultItems, userItems) { } if (!nextItems.root) { - nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" } + nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", disabled: "", action: "", provider: "" } nextOrder.unshift("root") } for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3 @@ -270,10 +271,20 @@ function isVisible(items, itemOrder, whenResults, entry, depth) { return false } -function labelFor(entry, checkedResults) { +// A `disabled:` row stays listed but goes dim and unselectable. The +// Install submenus use it so software already on the machine reads as +// installed rather than disappearing from the list it was installed from. +function isDisabled(disabledResults, entry) { + if (!entry || !entry.disabled) return false + return !!(disabledResults && disabledResults[entry.id]) +} + +// A disabled row is software you already have, which is the same thing the ✓ +// says everywhere else in the menu, so it earns the same marker. +function labelFor(entry, checkedResults, disabledResults) { if (!entry) return "" - if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓" - return entry.label + var marked = (entry.checked && checkedResults && checkedResults[entry.id]) || isDisabled(disabledResults, entry) + return marked ? entry.label + " ✓" : entry.label } function searchableToken(value) { @@ -351,16 +362,17 @@ function searchScore(items, entry, query) { return score * 1000 + depthFor(items, entry.id) * 25 + entry.order } -function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) { +function displayRow(items, itemOrder, checkedResults, disabledResults, entry, detail, score, section) { var target = entry.kind === "link" ? entry.target : entry.id return { itemId: entry.id, + disabled: isDisabled(disabledResults, entry), kind: entry.kind, icon: entry.icon, iconFont: entry.iconFont || "", appIcon: entry.appIcon || "", appId: entry.appId || "", - label: labelFor(entry, checkedResults), + label: labelFor(entry, checkedResults, disabledResults), target: target, detail: detail || "", path: pathFor(items, entry.id), @@ -459,10 +471,10 @@ function guardLine(id, tag, expression) { + id + ":" + tag + ":1; else echo " + id + ":" + tag + ":0; fi\n" } -// One bash script for every `when:` and `checked:` in the menu, reporting -// `::<0|1>` per line. Speed is the whole point: the menu opens on -// the last evaluation's answers, so however long this takes is how long a row -// can contradict the state it describes. +// One bash script for every `when:`, `checked:` and `disabled:` in the menu, +// reporting `::<0|1>` per line. Speed is the whole point: the menu +// opens on the last evaluation's answers, so however long this takes is how +// long a row can contradict the state it describes. function guardScript(items) { var guards = "" var ids = Object.keys(items || {}) @@ -472,6 +484,7 @@ function guardScript(items) { if (!entry) continue if (entry.when) guards += guardLine(ids[i], "w", entry.when) if (entry.checked) guards += guardLine(ids[i], "c", entry.checked) + if (entry.disabled) guards += guardLine(ids[i], "d", entry.disabled) } return guards ? guardPrelude(guards) + guards : "" @@ -497,6 +510,7 @@ if (typeof module !== "undefined") { isDescendantOf: isDescendantOf, childCount: childCount, isVisible: isVisible, + isDisabled: isDisabled, labelFor: labelFor, searchableToken: searchableToken, leafIdFor: leafIdFor, diff --git a/test/shell.d/menu-guards-test.sh b/test/shell.d/menu-guards-test.sh index 4062f492..243adf3b 100755 --- a/test/shell.d/menu-guards-test.sh +++ b/test/shell.d/menu-guards-test.sh @@ -10,6 +10,7 @@ const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') const items = { 'setup.default.browser.brave': { id: 'setup.default.browser.brave', when: 'omarchy-pkg-present brave-bin', checked: '[[ "$(omarchy-default-browser)" == "brave" ]]' }, 'setup.default.browser.zen': { id: 'setup.default.browser.zen', when: 'omarchy-pkg-present zen-browser-bin', checked: '[[ "$(omarchy-default-browser)" == "zen" ]]' }, + 'install.browser.zen': { id: 'install.browser.zen', disabled: 'omarchy-pkg-present zen-browser-bin' }, 'plain': { id: 'plain', label: 'No guards' } } const script = menu.guardScript(items) @@ -23,7 +24,11 @@ assert( script.includes('then echo setup.default.browser.zen:c:1; else echo setup.default.browser.zen:c:0; fi'), 'guard script reports a checked: as :c:<0|1>' ) -assert(!/\bplain:[wc]:/.test(script), 'guard script skips items with nothing to evaluate') +assert( + script.includes('if { omarchy-pkg-present zen-browser-bin; } >/dev/null 2>&1; then echo install.browser.zen:d:1; else echo install.browser.zen:d:0; fi'), + 'guard script reports a disabled: as :d:<0|1>' +) +assert(!/\bplain:[wcd]:/.test(script), 'guard script skips items with nothing to evaluate') assertEqual(menu.guardScript({ plain: items.plain }), '', 'guard script is empty when no item carries a guard') // The cost the menu is paying is per fork, not per expression, so what makes @@ -64,7 +69,7 @@ assert( // keeps forking once per row that reads it. const fs = require('fs') const defaultItems = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')) -const guardText = defaultItems.map(item => `${item.when}\n${item.checked}`).join('\n') +const guardText = defaultItems.map(item => `${item.when}\n${item.checked}\n${item.disabled}`).join('\n') const repeated = [...new Set( (guardText.match(/\$\((omarchy-[a-z0-9-]+)\)/g) || []).map(match => match.slice(2, -1)) )].filter(command => guardText.split(`$(${command})`).length > 2) diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index ce7dae98..db37c5f8 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -43,7 +43,8 @@ assertDeepEqual( provider: '', aliases: ['theme'], when: '', - checked: '' + checked: '', + disabled: '' }, 'menu normalizes parsed items' ) @@ -63,6 +64,8 @@ assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu buil assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects descendants') 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') +assertEqual(menu.labelFor({ id: 'install.browser.zen', label: 'Zen', disabled: 'cmd' }, {}, { 'install.browser.zen': true }), 'Zen ✓', 'menu marks a disabled row as something you already have') +assertEqual(menu.labelFor({ id: 'install.browser.zen', label: 'Zen', disabled: 'cmd' }, {}, { 'install.browser.zen': false }), 'Zen', 'menu leaves an uninstalled row unmarked') const visibilityItems = { hardware: menu.normalizeItem('hardware', { label: 'Hardware' }), @@ -78,6 +81,22 @@ assert(menu.isVisible(visibilityItems, visibilityOrder, { 'hardware.laptop': tru 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') +// `disabled:` is the softer guard: the row stays listed and only loses the +// cursor, which is how an already-installed app keeps its place in Install. +const installed = menu.normalizeItem('install.browser.zen', { label: 'Zen', disabled: 'omarchy-pkg-present zen-browser-bin', action: 'install-zen' }) +assert(menu.isVisible({ 'install.browser.zen': installed }, ['install.browser.zen'], { 'install.browser.zen': false }, installed), 'menu keeps a disabled row visible') +assert(menu.isDisabled({ 'install.browser.zen': true }, installed), 'menu disables a row whose disabled: succeeded') +assert(!menu.isDisabled({ 'install.browser.zen': false }, installed), 'menu leaves a row selectable when its disabled: failed') +assert(!menu.isDisabled({ 'install.browser.zen': true }, visibilityItems.laptop), 'menu never disables a row that declares no disabled:') +assert( + menu.displayRow({ 'install.browser.zen': installed }, ['install.browser.zen'], {}, { 'install.browser.zen': true }, installed, '', 0).disabled, + 'menu display rows carry their disabled state' +) +assert( + /function matchesQuery\(entry, query\) \{\s*\n\s*return MenuModel\.matchesQuery\(entry, query, root\.isVisible\(entry\) && !root\.isDisabled\(entry\)\)/.test(menuQml), + 'menu search skips disabled rows, which belong to the submenu they sit in rather than a list of what you can do' +) + 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') @@ -86,9 +105,10 @@ assert(!menu.matchesQuery(entry, 'theme', false), 'menu hides invisible matches' assert(menu.searchScore(merged.items, entry, 'theme') < menu.searchScore(merged.items, entry, 'appearance'), 'menu scores name matches above description matches') assertDeepEqual( - menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'), + menu.displayRow(merged.items, merged.itemOrder, {}, {}, entry, 'Style', 12, 'search'), { itemId: 'style.theme', + disabled: false, kind: 'action', icon: '', iconFont: '', @@ -235,6 +255,40 @@ assert( 'menu always exposes every supported browser, terminal, and editor under Defaults' ) assert(!defaultById['install.ai.crush'], 'menu removes Crush from Install > AI') +// Software you already have keeps its place in Install, dimmed rather than +// dropped, so the list reads as a catalog of what Omarchy can install. +// Chromium Account is the sole Install row with anything left to hide for, so +// any other `when:` here is a row that went back to vanishing once installed. +assertDeepEqual( + defaultItems + .filter(item => item.id.startsWith('install.') && item.action && item.when) + .map(item => item.id), + ['install.service.chromium-account'], + 'menu never hides an Install row because the software is already there' +) +assert( + ['install.browser.zen', 'install.editor.vscode', 'install.gaming.steam', 'install.development.rust', 'install.windows'].every( + id => defaultById[id].disabled && !defaultById[id].when + ), + 'menu dims the Install rows for software that is already installed' +) +assertEqual( + defaultById['install.browser.zen'].disabled, + 'omarchy-pkg-present zen-browser-bin', + 'menu asks the same presence question it used to hide the row with' +) +// A guard can still be about something other than having the software: no +// Chromium at all means no account to wire up, and that row stays hidden. +assert( + defaultById['install.service.chromium-account'].when === '[[ -f ~/.config/chromium-flags.conf ]]' + && defaultById['install.service.chromium-account'].disabled.includes('oauth2-client-id'), + 'menu keeps hiding Chromium Account without Chromium, and dims it once the account is set up' +) +assert( + defaultItems.filter(item => item.id.startsWith('remove.')).every(item => !item.disabled) + && defaultById['remove.browser.zen'].when === 'omarchy-pkg-present zen-browser-bin', + 'menu still hides Remove rows for software that is not installed' +) assert( defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'), 'menu places Passwordless Sudo under Setup > Security' @@ -367,6 +421,40 @@ assert( /function select\(delta\)[\s\S]*root\.disarmPointer\(\)[\s\S]*selectedIndex =/.test(menuQml), 'menu keyboard navigation disarms pointer selection' ) +// A dimmed row is not a target: the cursor steps over it, the pointer refuses +// to land on it, and neither Enter nor a click can reach it. +assert( + /function select\(delta\)[\s\S]*?var target = root\.nextSelectable\(from, delta\)\s*\n\s*if \(target < 0\) return/.test(menuQml), + 'menu keyboard navigation skips disabled rows in the direction of travel' +) +assert( + /function rowSelectable\(index\)[\s\S]*?return !displayModel\.get\(index\)\.disabled/.test(menuQml), + 'menu reads selectability off the row' +) +assert( + /function activateIndex\(index, fromPointer\)[\s\S]*?if \(!root\.rowSelectable\(index\)\) return/.test(menuQml), + 'menu refuses to activate a disabled row' +) +assert( + /function selectFromPointer\(index, item, mouse\)[\s\S]*?if \(!root\.rowSelectable\(index\)\) return/.test(menuQml) + && /onClicked: \{\s*\n\s*if \(row\.disabled\) return/.test(menuQml), + 'menu leaves the cursor put when the pointer crosses a disabled row' +) +assert( + /opacity: row\.disabled \? 0\.4 : 1/.test(menuQml) && !/font\.italic/.test(menuQml), + 'menu renders a disabled row faded, and leaves it at that' +) +assert( + /function rebuildDisplay\(\)[\s\S]*?root\.settleCursor\(\)/.test(menuQml), + 'menu parks the cursor on a selectable row after the rows change' +) +// A menu with nothing selectable in it has no cursor, and Return must not +// conjure one onto a disabled row just because rows exist. +assert( + /function settleCursor\(\)[\s\S]*?root\.cursorActive = target >= 0/.test(menuQml) + && /else if \(root\.cursorActive\) root\.activateIndex\(root\.selectedIndex\)\s*\n\s*else root\.settleCursor\(\)/.test(menuQml), + 'menu ties the cursor to a selectable row existing, both ways' +) assert( /function setFilter\(nextFilter\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml), 'menu filter changes disarm pointer selection'