From 1409f857458e1cfc0c46004043c7a381a146d952 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 5 Jun 2026 16:49:30 -0400 Subject: [PATCH] Make the bar more easily swapable --- bin/omarchy-config-shell-bar | 180 +++++++++++++++++- bin/omarchy-plugin | 26 ++- docs/omarchy-shell.md | 35 ++-- shell/README.md | 37 ++-- shell/plugins/README.md | 10 +- shell/plugins/bar/Bar.qml | 3 + shell/services/PluginRegistry.qml | 37 +++- shell/shell.qml | 113 ++++++++++- test/shell.d/config-test.sh | 29 +++ .../fixtures/plugin-registry/shell.qml | 17 +- 10 files changed, 430 insertions(+), 57 deletions(-) diff --git a/bin/omarchy-config-shell-bar b/bin/omarchy-config-shell-bar index cf5c0efd..56b2c394 100755 --- a/bin/omarchy-config-shell-bar +++ b/bin/omarchy-config-shell-bar @@ -1,8 +1,8 @@ #!/bin/bash -# omarchy:summary=Mutate the user shell.json bar layout -# omarchy:args=show | list [--json] [--all] | add [plugin] [left|center|right] | remove [plugin] | drop [plugin] | position | transparent -# omarchy:examples=omarchy config shell bar show | omarchy config shell bar list | omarchy config shell bar add | omarchy config shell bar remove | omarchy config shell bar add omarchy.tailscale | omarchy config shell bar position top | omarchy config shell bar transparent true +# omarchy:summary=Mutate the user shell.json bar layout and active bar option +# omarchy:args=show | options [--json] | use | reset | list [--json] [--all] | add [plugin] [left|center|right] | remove [plugin] | drop [plugin] | position | transparent +# omarchy:examples=omarchy config shell bar show | omarchy config shell bar options | omarchy config shell bar use local.neon-bar | omarchy config shell bar reset | omarchy config shell bar list | omarchy config shell bar add omarchy.tailscale | omarchy config shell bar position top | omarchy config shell bar transparent true set -euo pipefail @@ -11,7 +11,7 @@ OMARCHY_ROOT="${OMARCHY_PATH:-}" DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json" usage() { - echo "Usage: omarchy-config-shell-bar show | list [--json] [--all] | add [plugin] [left|center|right] | remove [plugin] | drop [plugin] | position | transparent " >&2 + echo "Usage: omarchy-config-shell-bar show | options [--json] | use | reset | list [--json] [--all] | add [plugin] [left|center|right] | remove [plugin] | drop [plugin] | position | transparent " >&2 } fail() { @@ -49,6 +49,95 @@ bar_widget_manifest_paths() { } | sort -u } +bar_option_manifest_paths() { + { + if [[ -n $OMARCHY_ROOT && -d $OMARCHY_ROOT/shell/plugins ]]; then + find "$OMARCHY_ROOT/shell/plugins" -mindepth 2 -maxdepth 4 -type f \( -name manifest.json -o -name '*.manifest.json' \) 2>/dev/null + fi + + if [[ -d $HOME/.config/omarchy/plugins ]]; then + find -L "$HOME/.config/omarchy/plugins" -mindepth 2 -maxdepth 2 -type f -name manifest.json 2>/dev/null + fi + } | sort -u +} + +current_bar_option_id() { + jq -r '(.bar.id // "omarchy.bar") | tostring' "$(source_file)" +} + +available_bar_options_json() { + local current_id + local manifest_paths=() + + current_id=$(current_bar_option_id) + mapfile -t manifest_paths < <(bar_option_manifest_paths) + + if (( ${#manifest_paths[@]} == 0 )); then + printf '[]\n' + return + fi + + jq -s --arg currentId "$current_id" ' + map( + select(((.kinds // []) | index("bar")) and ((.entryPoints.bar // "") != "")) | + { + id: (.id // ""), + name: (.name // .id // ""), + description: (.description // ""), + firstParty: ((.id // "") | startswith("omarchy.")), + active: ((.id // "") == $currentId) + } | + select(.id != "") + ) | + unique_by(.id) | + sort_by((if .id == "omarchy.bar" then 0 else 1 end), .name, .id) + ' "${manifest_paths[@]}" +} + +print_bar_options() { + local json="false" + + while (( $# > 0 )); do + case "$1" in + --json) + json="true" + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown options option: $1" + ;; + esac + shift + done + + if [[ $json == "true" ]]; then + available_bar_options_json + return + fi + + available_bar_options_json | jq -r ' + .[] | + [ + .id, + (if .active then "active" else "available" end), + (if .firstParty then "built-in" else "plugin" end), + .name, + .description + ] | @tsv + ' | awk -F '\t' ' + BEGIN { printf "%-32s %-10s %-8s %-22s %s\n", "ID", "STATUS", "SOURCE", "NAME", "DESCRIPTION" } + { printf "%-32s %-10s %-8s %-22s %s\n", $1, $2, $3, $4, $5 } + ' +} + +bar_option_exists() { + local id="$1" + available_bar_options_json | jq -e --arg id "$id" 'any(.[]; .id == $id)' >/dev/null +} + current_bar_ids_json() { jq -c ' def array_or_empty: if type == "array" then . else [] end; @@ -256,7 +345,88 @@ case "$command" in jq '.bar // {}' "$(source_file)" ;; - list|available|options) + selected|active) + if (( $# != 1 )); then + usage + exit 1 + fi + + current_bar_option_id + ;; + options|option) + shift + print_bar_options "$@" + ;; + use) + if (( $# != 2 )); then + usage + exit 1 + fi + + plugin="${2:-}" + [[ -n $plugin ]] || fail "bar option id is required" + if [[ $plugin == "default" || $plugin == "built-in" ]]; then + plugin="omarchy.bar" + fi + bar_option_exists "$plugin" || fail "$plugin is not a known bar option; run 'omarchy config shell bar options'" + + mkdir -p "$(dirname "$CONFIG_FILE")" + + source="$(source_file)" + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + + if [[ $plugin == "omarchy.bar" ]]; then + jq ' + def object_or_empty: if type == "object" then . else {} end; + + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | del(.bar.id) + ' "$source" >"$tmp" + else + jq --arg plugin "$plugin" ' + def object_or_empty: if type == "object" then . else {} end; + + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | .bar.id = $plugin + ' "$source" >"$tmp" + fi + + mv "$tmp" "$CONFIG_FILE" + trap - EXIT + omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true + refresh_shell_config + ;; + reset) + if (( $# != 1 )); then + usage + exit 1 + fi + + mkdir -p "$(dirname "$CONFIG_FILE")" + + source="$(source_file)" + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + + jq ' + def object_or_empty: if type == "object" then . else {} end; + + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | del(.bar.id) + ' "$source" >"$tmp" + + mv "$tmp" "$CONFIG_FILE" + trap - EXIT + refresh_shell_config + ;; + list|available|widgets) shift print_available_widgets "$@" ;; diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin index d2ce7fcc..bb411ef2 100755 --- a/bin/omarchy-plugin +++ b/bin/omarchy-plugin @@ -41,9 +41,13 @@ Clone options: --prompt Initial prompt when opening with AI --replace Replace the first source bar instance --add [placement] Add cloned bar widget to the bar + --use Use cloned bar options as the active bar Bar widget commands: bar settings Open the inline bar config panel + bar options [--json] List available bar options + bar use Use a bar option as the active bar + bar reset Return to the built-in Omarchy bar bar list [--json] List current bar layout bar add [placement] [--duplicate] bar move [placement] [--from-section SECTION] [--from-index N] @@ -65,6 +69,9 @@ Examples: omarchy plugin edit local.clock --with editor omarchy plugin edit local.clock --with ai --prompt "Customize this clock" omarchy plugin enable acme.weather --section right + omarchy plugin bar options + omarchy plugin bar use local.neon-bar + omarchy plugin bar reset omarchy plugin bar add acme.weather --section right --before omarchy.tray omarchy plugin bar move omarchy.clock --section center --index 0 omarchy plugin bar remove acme.weather @@ -705,7 +712,7 @@ clone_source_options() { first_party_manifest_paths | while IFS= read -r manifest; do id=$(jq -r '.id // empty' "$manifest" 2>/dev/null || true) name=$(jq -r '.barWidget.displayName // .name // .id // empty' "$manifest" 2>/dev/null || true) - [[ -n $id && $id != "omarchy.bar" ]] || continue + [[ -n $id ]] || continue if jq -e '(.kinds // []) | index("bar-widget")' "$manifest" >/dev/null 2>&1; then printf '%s\tBuilt-in widget\t%s\n' "$id" "${name:-$id}" else @@ -1030,6 +1037,7 @@ clone_command() { local prompt="" local replace="false" local add="false" + local use_bar="false" local placement=() while (( $# > 0 )); do @@ -1061,6 +1069,10 @@ clone_command() { add="true" shift ;; + --use) + use_bar="true" + shift + ;; --section | --index | --before | --after) placement+=("$1" "${2:-}") shift 2 @@ -1155,6 +1167,9 @@ clone_command() { elif [[ $add == "true" ]]; then bar_add "$new_id" "${placement[@]}" echo "Cloned $source_id to $new_id" + elif [[ $use_bar == "true" ]]; then + omarchy-config-shell-bar use "$new_id" + echo "Cloned $source_id to $new_id and selected it as the active bar option" else echo "Cloned $source_id to $new_id" fi @@ -1176,6 +1191,15 @@ bar_command() { (( $# == 0 )) || fail "bar settings does not take arguments" exec omarchy-launch-bar-settings ;; + options | option) + exec omarchy-config-shell-bar options "$@" + ;; + use) + exec omarchy-config-shell-bar use "$@" + ;; + reset) + exec omarchy-config-shell-bar reset "$@" + ;; list) bar_list "$@" ;; diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 55ed5481..157da7c2 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -25,12 +25,15 @@ first call. | Kind | What it is | |--------------|---------------------------------------------| -| `bar-widget` | Component the bar drops into a section | -| `panel` | Floating window (e.g. OSD) | -| `overlay` | Fullscreen overlay (e.g. background picker) | -| `menu` | Summoned menu surface | -| `service` | Headless singleton, no UI | +| `bar-widget` | Component the active bar drops into a section | +| `bar` | Full bar option that can replace `omarchy.bar` | +| `panel` | Floating window (e.g. OSD) | +| `overlay` | Fullscreen overlay (e.g. background picker) | +| `menu` | Summoned menu surface | +| `service` | Headless singleton, no UI | +Only one full bar option is active at a time. The built-in `omarchy.bar` is +used when `bar.id` is omitted or when a selected third-party bar cannot load. Panels, overlays, and menus are loaded when summoned. Plugins can set `keepLoaded: true` to survive between summons. First-party services are loaded at startup. @@ -59,7 +62,8 @@ every prompt (the path for scripts and agents). Sources are recorded in `~/.config/omarchy/plugins/sources.json` and cloned into `~/.cache/omarchy/plugin-sources/`. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy plugin rescan`, then -`omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `). +`omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `; +full bar replacements are selected with `omarchy plugin bar use `). The lower-level IPC methods remain available through `omarchy-shell shell ...`. ## IPC @@ -91,6 +95,7 @@ individual plugins (`bar`, `image-selector`, …). "lock": 300 }, "bar": { + "id": "omarchy.bar", "position": "top", "transparent": false, "centerAnchor": "calendar", @@ -109,16 +114,20 @@ individual plugins (`bar`, `image-selector`, …). Rules: -1. Every plugin instance is one entry — `bar.layout.
` for +1. The active bar option is `bar.id`. Omit it or set it to `omarchy.bar` for + the built-in bar; set it to a plugin whose manifest declares `kind: "bar"` + to replace the full bar. +2. Every plugin instance is one entry — `bar.layout.
` for bar widgets, `plugins[]` for everything else. -2. Settings are inline on the entry. No `config:` sub-object, no +3. Settings are inline on the entry. No `config:` sub-object, no merge layers. -3. Built-in bar widget ids are namespaced (`omarchy.clock`, `omarchy.audio`, …). +4. Built-in bar widget ids are namespaced (`omarchy.clock`, `omarchy.audio`, …). The migration rewrites older ids such as `Clock` and `AudioPanel` forward. -4. Third-party enabled ⇔ present; first-party plugins are always enabled. -5. `allowMultiple: true` in the manifest permits multiple instances. -6. `idle.screensaver` and `idle.lock` are seconds since user idle began. -7. `version: 1` is required. +5. Third-party enabled ⇔ present; for full bar options that means `bar.id`. + First-party non-bar plugins are always enabled. +6. `allowMultiple: true` in the manifest permits multiple instances. +7. `idle.screensaver` and `idle.lock` are seconds since user idle began. +8. `version: 1` is required. `config/omarchy/shell.json` describes the fresh-install state. When no user `shell.json` exists, defaults are used verbatim. Once the user diff --git a/shell/README.md b/shell/README.md index c33d5fb2..3e886d9d 100644 --- a/shell/README.md +++ b/shell/README.md @@ -75,13 +75,15 @@ Supported `kinds`: | Kind | What it is | |--------------|--------------------------------------------------------------| -| `bar-widget` | A component that the bar can drop into a section | +| `bar-widget` | A component that the active bar can drop into a section | | `panel` | A persistent or summoned floating window (e.g. OSD) | | `overlay` | A fullscreen overlay (e.g. background switcher) | | `menu` | A summoned menu surface | | `service` | A headless singleton, no UI | -| `bar` | Reserved for the first-party bar host (`omarchy.bar`). Third-party plugins should ship `bar-widget`s; they do not replace the host bar. | +| `bar` | A full bar option that can replace the built-in `omarchy.bar` | +Only one `bar` plugin is active at a time. Missing or invalid selections fall +back to the built-in `omarchy.bar`, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need to outlive a single summon can set `keepLoaded: true` (e.g. the image picker keeps its overlay window mounted between summons). First-party @@ -132,7 +134,7 @@ You can still drop a plugin in without a source: 1. Put it in `~/.config/omarchy/plugins//` with a `manifest.json` plus the QML referenced from its `entryPoints`. 2. `omarchy plugin rescan`. -3. `omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `). +3. `omarchy plugin enable ` (bar widgets also need `omarchy plugin bar add `; full bar replacements are selected with `omarchy plugin bar use `). The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell setPluginEnabled true`, and `omarchy-shell shell listPlugins`. @@ -151,7 +153,9 @@ omarchy plugin edit local.clock --with ai # edit with `omarchy launch ai` ``` First-party plugins under `shell/plugins/` -are discovered the same way and cannot be disabled. +are discovered the same way and cannot be disabled, except that the built-in +bar option can become inactive while a third-party `kind: "bar"` plugin is the +selected bar. ## IPC contract @@ -224,6 +228,7 @@ becomes the authoritative file — we do **not** deep-merge defaults back in. "lock": 300 }, "bar": { + "id": "omarchy.bar", "position": "top", "transparent": false, "centerAnchor": "omarchy.clock", @@ -241,27 +246,31 @@ becomes the authoritative file — we do **not** deep-merge defaults back in. ### Storage rules -1. **Every plugin instance is one entry.** Either in `bar.layout.
` +1. **The active bar option is `bar.id`.** Omit it or set it to `omarchy.bar` + to use the built-in bar. Set it to another plugin id whose manifest declares + `kind: "bar"` to replace the full bar. +2. **Every plugin instance is one entry.** Either in `bar.layout.
` for bar widgets, or in `plugins[]` for panels, overlays, services, menus, and anything else non-bar. -2. **Settings are inline on the entry.** No `config:` sub-object, no +3. **Settings are inline on the entry.** No `config:` sub-object, no separate per-plugin settings file, no merge layers. The fields on each entry are the values the plugin sees. -3. **Built-in widget ids are namespaced.** Use ids such as `omarchy.clock`, +4. **Built-in widget ids are namespaced.** Use ids such as `omarchy.clock`, `omarchy.audio`, and `omarchy.network`. The migration rewrites older ids like `Clock` and `AudioPanel` forward. -4. **Third-party enabled ⇔ present.** A third-party plugin is enabled iff - its id appears somewhere in shell.json. For bar widgets, the bar - settings UI adds/removes layout entries; other plugin kinds are enabled - with the shell IPC. First-party plugins are always enabled. -5. **Multiple instances** are allowed when a manifest sets +5. **Third-party enabled ⇔ present.** A third-party plugin is enabled iff + its id appears somewhere in shell.json. For full bar options, that means + `bar.id`; for bar widgets, the bar settings UI adds/removes layout entries; + other plugin kinds are enabled with the shell IPC. First-party non-bar + plugins are always enabled. +6. **Multiple instances** are allowed when a manifest sets `allowMultiple: true`. Each instance is independent — e.g. two clock widgets in different timezones are just two `{"id":"omarchy.clock", "timezone": ...}` entries with their own values. -6. **Idle timings are top-level.** `idle.screensaver` and `idle.lock` +7. **Idle timings are top-level.** `idle.screensaver` and `idle.lock` are seconds since user idle began, so the default lock fires at 300s even if the 150s screensaver starts first. -7. **`version: 1` is required** at the top level. The shell will fall back +8. **`version: 1` is required** at the top level. The shell will fall back to defaults rather than load an unknown version. ## Implementation history diff --git a/shell/plugins/README.md b/shell/plugins/README.md index b7aa6270..c452b037 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -2,9 +2,11 @@ These plugins ship with Omarchy and are discovered by the shell at startup. They use the same `manifest.json` contract as third-party plugins; the -only difference is that the shell flags them with `__isFirstParty: true` -so they are always enabled. Services and keep-loaded panels are mounted at -startup; other panels, overlays, and menus are loaded on demand. +only difference is that the shell flags them with `__isFirstParty: true`. +First-party non-bar plugins are always enabled; `omarchy.bar` is the default +bar option and becomes inactive only while a third-party `kind: "bar"` plugin is +selected. Services and keep-loaded panels are mounted at startup; other panels, +overlays, and menus are loaded on demand. User-installed plugins live alongside these conceptually but on disk under `~/.config/omarchy/plugins//` rather than in this directory. @@ -40,7 +42,7 @@ own plugin directories, each with its own `manifest.json`. ## Bar -The status bar. Mounted at startup, lives forever. Layout lives in the +The built-in status bar and default full-bar option. Layout lives in the top-level `bar:` subtree of `~/.config/omarchy/shell.json` (with the shell providing [`config/omarchy/shell.json`](../../config/omarchy/shell.json) when the user has no file). See [`bar/README.md`](bar/README.md) for the widget catalogue diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 4862202c..0f99803d 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -22,6 +22,9 @@ Item { // Injected by the host shell. Used for shell-wide actions such as opening // settings and persisting inline widget state. property var shell: null + // Manifest for the active bar option. Present for custom bars and useful for + // diagnostics; the built-in bar does not otherwise need it. + property var manifest: null // Mirrors the on-disk `bar-off` flag so the user can hide the bar without // killing the entire shell. Wired to BarPanel.visible below; updated by the // FileView watcher further down. diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index 7b9ec536..b7637248 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -97,29 +97,41 @@ QtObject { } // Enabled = the plugin id is referenced somewhere in shell.json. That can - // be either a layout entry inside `bar.layout.*` (bar widgets) or a top-level - // entry in `plugins[]` (panels, overlays, services). + // be either the active bar option in `bar.id`, a layout entry inside + // `bar.layout.*` (bar widgets), or a top-level entry in `plugins[]` (panels, + // overlays, services). // // Special cases (implicitly always enabled, no shell.json entry needed): - // - plugins whose `kinds` contains "bar" are mounted directly by the host. - // - first-party plugins are shell infrastructure (settings, + // - the built-in bar option (`omarchy.bar`) is active when `bar.id` is + // missing or set to `omarchy.bar`. + // - first-party non-bar plugins are shell infrastructure (settings, // image-picker, ...). Requiring users to add them to plugins[] just to // summon them was a footgun: a stock shell.json with `plugins: []` would // silently make `omarchy launch bar-settings` a no-op. function isEnabled(id) { var key = String(id) var manifest = installedPlugins[key] + var config = shellConfigProvider ? shellConfigProvider() : null if (manifest) { - if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true + if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) { + var selectedBar = "" + if (Util.isPlainObject(config) && Util.isPlainObject(config.bar)) + selectedBar = Util.canonicalWidgetId(String(config.bar.id || "")) + if (!selectedBar) selectedBar = "omarchy.bar" + return selectedBar === key + } if (manifest.__isFirstParty) return true } - var config = shellConfigProvider ? shellConfigProvider() : null return findEntryLocation(config, key).found } function findEntryLocation(config, id) { if (!Util.isPlainObject(config)) return { found: false } var key = Util.canonicalWidgetId(String(id)) + if (Util.isPlainObject(config.bar)) { + var selectedBar = Util.canonicalWidgetId(String(config.bar.id || "")) + if (selectedBar === key) return { found: true, kind: "bar-option" } + } if (Util.isPlainObject(config.bar) && Util.isPlainObject(config.bar.layout)) { var sections = ["left", "center", "right"] for (var s = 0; s < sections.length; s++) { @@ -148,12 +160,23 @@ QtObject { return } var manifest = installedPlugins[key] + var isBarOption = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1 var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 shellConfigMutator(function(config) { // Ensure shape exists. if (!Util.isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } if (!Util.isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] } if (!Array.isArray(config.plugins)) config.plugins = [] + + if (isBarOption) { + if (value) { + config.bar.id = key + } else if (Util.canonicalWidgetId(String(config.bar.id || "")) === key) { + delete config.bar.id + } + return + } + var location = findEntryLocation(config, key) if (value && !location.found) { var entry = { id: key } @@ -166,7 +189,7 @@ QtObject { } else if (!value && location.found) { if (location.kind === "bar") { config.bar.layout[location.section].splice(location.index, 1) - } else { + } else if (location.kind === "plugin") { config.plugins.splice(location.index, 1) } } diff --git a/shell/shell.qml b/shell/shell.qml index dae7bf8d..095f86be 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -56,6 +56,7 @@ ShellRoot { property bool suppressUserReload: false onShellConfigChanged: { + if (failedBarId !== "") failedBarId = "" pluginRegistry.registryRevision++ pluginRegistry.pluginsChanged() } @@ -105,6 +106,7 @@ ShellRoot { } readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar + onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig FileView { id: defaultsFile path: shell.defaultsPath @@ -159,15 +161,102 @@ ShellRoot { } // Exposed as a property so child plugins (notifications, future panels) - // can read barSize/barHidden/position to anchor relative to the bar. - property alias bar: bar + // can read barSize/barHidden/position to anchor relative to the active bar. + readonly property string defaultBarId: "omarchy.bar" + readonly property string selectedBarId: { + var config = shell.barConfig + if (Util.isPlainObject(config)) { + var configured = Util.canonicalWidgetId(String(config.id || "")) + if (configured) return configured + } + return shell.defaultBarId + } + property string failedBarId: "" + readonly property bool selectedBarAvailable: { + var revision = shell.pluginRegistry.registryRevision + return shell.barOptionAvailable(shell.selectedBarId) + } + readonly property string activeBarId: selectedBarId !== failedBarId && selectedBarAvailable ? selectedBarId : defaultBarId + readonly property var activeBarManifest: { + var revision = shell.pluginRegistry.registryRevision + return shell.barManifestFor(shell.activeBarId) + } + readonly property string activeBarSourceUrl: activeBarId === defaultBarId ? "" : shell.pluginRegistry.entryPointUrl(activeBarManifest, "bar") + property var bar: null - Bar { - id: bar - omarchyPath: shell.omarchyPath - barWidgetRegistry: shell.barWidgetRegistry - barConfig: shell.barConfig - shell: shell + onSelectedBarIdChanged: if (failedBarId !== "") failedBarId = "" + + function barManifestFor(pluginId) { + var plugins = shell.pluginRegistry ? shell.pluginRegistry.installedPlugins : null + return plugins ? plugins[String(pluginId || "")] || null : null + } + + function isBarOptionManifest(manifest) { + return manifest + && Array.isArray(manifest.kinds) + && manifest.kinds.indexOf("bar") !== -1 + && manifest.entryPoints + && manifest.entryPoints.bar + } + + function barOptionAvailable(pluginId) { + var id = String(pluginId || "") + if (id === "" || id === shell.defaultBarId) return true + var manifest = shell.barManifestFor(id) + return shell.isBarOptionManifest(manifest) && shell.pluginRegistry.entryPointUrl(manifest, "bar") !== "" + } + + function isActiveBarOption(pluginId) { + return String(pluginId || "") === shell.activeBarId + } + + function configureBar(target, manifest) { + if (!target) return + if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath + if ("shell" in target) target.shell = shell + if ("manifest" in target) target.manifest = manifest + if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry + if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry + if ("barConfig" in target) target.barConfig = shell.barConfig + shell.bar = target + } + + Component { + id: defaultBarComponent + + Bar { + omarchyPath: shell.omarchyPath + barWidgetRegistry: shell.barWidgetRegistry + barConfig: shell.barConfig + shell: shell + manifest: shell.barManifestFor(shell.defaultBarId) + } + } + + Loader { + id: defaultBarLoader + + active: shell.activeBarId === shell.defaultBarId + sourceComponent: defaultBarComponent + onLoaded: shell.configureBar(item, shell.barManifestFor(shell.defaultBarId)) + onActiveChanged: if (!active && shell.activeBarId !== shell.defaultBarId) shell.bar = null + } + + Loader { + id: pluginBarLoader + + active: shell.activeBarId !== shell.defaultBarId && shell.activeBarSourceUrl !== "" + source: shell.activeBarId !== shell.defaultBarId ? shell.activeBarSourceUrl : "" + asynchronous: true + onLoaded: shell.configureBar(item, shell.activeBarManifest) + onActiveChanged: if (!active && shell.activeBarId === shell.defaultBarId) shell.bar = null + onStatusChanged: { + if (status === Loader.Error) { + var detail = errorString && errorString() ? errorString() : "" + console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail) + shell.failedBarId = shell.activeBarId + } + } } // ------------------------------------------------------------- services @@ -639,11 +728,15 @@ ShellRoot { var out = [] var plugins = shell.pluginRegistry.installedPlugins for (var id in plugins) { + var kinds = plugins[id].kinds || [] + var isBarOption = Array.isArray(kinds) && kinds.indexOf("bar") !== -1 + var active = isBarOption && shell.isActiveBarOption(id) out.push({ id: id, name: plugins[id].name, - kinds: plugins[id].kinds, - enabled: shell.pluginRegistry.isEnabled(id), + kinds: kinds, + enabled: isBarOption ? active : shell.pluginRegistry.isEnabled(id), + active: active, firstParty: !!plugins[id].__isFirstParty }) } diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 853f8dba..22163739 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -114,6 +114,35 @@ cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' } JSON +mkdir -p "$TMPDIR/home/.config/omarchy/plugins/local.demo-bar" +cat >"$TMPDIR/home/.config/omarchy/plugins/local.demo-bar/manifest.json" <<'JSON' +{ + "schemaVersion": 1, + "id": "local.demo-bar", + "name": "Demo bar", + "version": "1.0.0", + "author": "Test", + "description": "Replacement bar for config tests", + "kinds": ["bar"], + "entryPoints": { "bar": "Bar.qml" } +} +JSON +touch "$TMPDIR/home/.config/omarchy/plugins/local.demo-bar/Bar.qml" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar options --json | jq -e ' + any(.[]; .id == "omarchy.bar" and .active == true) and + any(.[]; .id == "local.demo-bar" and .active == false) +' >/dev/null +pass "shell config lists bar options" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar use local.demo-bar +jq -e '.bar.id == "local.demo-bar"' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "shell config selects a bar option" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar reset +jq -e '.bar.id == null' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "shell config resets to built-in bar option" + HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar add omarchy.tailscale jq -e ' def ids: map(.id // .); diff --git a/test/shell.d/fixtures/plugin-registry/shell.qml b/test/shell.d/fixtures/plugin-registry/shell.qml index 38b4c163..c808e966 100644 --- a/test/shell.d/fixtures/plugin-registry/shell.qml +++ b/test/shell.d/fixtures/plugin-registry/shell.qml @@ -78,10 +78,11 @@ ShellRoot { function runChecks() { var scan = "" scan += block("firstparty", "/first/widgets/clock", manifest("omarchy.first-widget", ["bar-widget"], { barWidget: "Widget.qml" })) - scan += block("firstparty", "/first/bar", manifest("omarchy.first-bar", ["bar"], { bar: "Bar.qml" })) + scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" })) scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" })) + scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" })) scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" })) @@ -92,9 +93,10 @@ ShellRoot { registry.parseScanOutput(scan) root.assertDeepEqual(pluginIds(), [ - "omarchy.first-bar", + "omarchy.bar", "omarchy.first-widget", "omarchy.grouped-panel", + "third.bar", "third.panel", "third.widget" ], "registry merges valid first-party and third-party manifests") @@ -111,9 +113,18 @@ ShellRoot { root.assertTrue(!has("third.schema"), "unsupported schema versions are rejected") root.assertTrue(registry.isEnabled("omarchy.first-widget"), "first-party plugins are implicitly enabled") - root.assertTrue(registry.isEnabled("omarchy.first-bar"), "bar plugins are implicitly enabled") + root.assertTrue(registry.isEnabled("omarchy.bar"), "built-in bar option is active by default") + root.assertTrue(!registry.isEnabled("third.bar"), "third-party bar options start inactive") root.assertTrue(!registry.isEnabled("third.panel"), "third-party plugins start disabled") + registry.setEnabled("third.bar", true) + root.assertEqual(root.config.bar.id, "third.bar", "enabling third-party bar options writes bar id") + root.assertTrue(registry.isEnabled("third.bar"), "selected third-party bar options are enabled") + root.assertTrue(!registry.isEnabled("omarchy.bar"), "selecting third-party bar options deactivates built-in bar") + registry.setEnabled("third.bar", false) + root.assertTrue(root.config.bar.id === undefined, "disabling active bar options resets to built-in") + root.assertTrue(registry.isEnabled("omarchy.bar"), "built-in bar option returns after reset") + registry.setEnabled("third.panel", true) root.assertDeepEqual(root.config.plugins, [{ id: "third.panel" }], "enabling third-party panels writes plugins array") root.assertTrue(registry.isEnabled("third.panel"), "enabled third-party panels are found")