diff --git a/bin/omarchy-config-shell-bar b/bin/omarchy-config-shell-bar index 87e7c0a8..cf5c0efd 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=add [left|center|right] | drop | position | transparent | show -# omarchy:examples=omarchy config shell bar add omarchy.tailscale | omarchy config shell bar position top | omarchy config shell bar transparent true | omarchy config shell bar show +# 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 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 add [left|center|right] | drop | position | transparent | show" >&2 + echo "Usage: omarchy-config-shell-bar show | list [--json] [--all] | add [plugin] [left|center|right] | remove [plugin] | drop [plugin] | position | transparent " >&2 } fail() { @@ -19,6 +19,12 @@ fail() { exit 1 } +refresh_shell_config() { + if ! omarchy-shell shell reloadConfig >/dev/null 2>&1; then + omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true + fi +} + source_file() { local source="$CONFIG_FILE" @@ -31,10 +37,218 @@ source_file() { printf '%s\n' "$source" } +bar_widget_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_ids_json() { + jq -c ' + def array_or_empty: if type == "array" then . else [] end; + def entry_id: if type == "object" then (.id // "" | tostring) else tostring end; + + ["left", "center", "right"] as $sections | + [ $sections[] as $section | + ((.bar.layout[$section] // []) | array_or_empty)[] | + entry_id | + select(length > 0) + ] + ' "$(source_file)" +} + +current_bar_entries_json() { + jq -c ' + def array_or_empty: if type == "array" then . else [] end; + def entry_id($entry): if ($entry | type) == "object" then ($entry.id // "" | tostring) else ($entry | tostring) end; + + ["left", "center", "right"] as $sections | + [ $sections[] as $section | + ((.bar.layout[$section] // []) | array_or_empty | to_entries[]) | + { section: $section, index: .key, id: entry_id(.value) } | + select(.id | length > 0) + ] + ' "$(source_file)" +} + +available_widgets_json() { + local include_all="$1" + local current_ids + local manifest_paths=() + + current_ids=$(current_bar_ids_json) + mapfile -t manifest_paths < <(bar_widget_manifest_paths) + + if (( ${#manifest_paths[@]} == 0 )); then + printf '[]\n' + return + fi + + jq -s --argjson currentIds "$current_ids" --argjson includeAll "$include_all" ' + map( + select(((.kinds // []) | index("bar-widget")) and ((.entryPoints.barWidget // "") != "")) | + { + id: (.id // ""), + name: (.barWidget.displayName // .name // .id // ""), + description: (.barWidget.description // .description // ""), + category: (.barWidget.category // "Plugin"), + allowMultiple: (.barWidget.allowMultiple == true) + } | + select(.id != "") + ) | + unique_by(.id) | + map(. as $widget | + $widget + { + inBar: (($currentIds | index($widget.id)) != null), + addable: ($widget.allowMultiple or (($currentIds | index($widget.id)) == null)) + } + ) | + map(select($includeAll or .addable)) | + sort_by(.category, .name, .id) + ' "${manifest_paths[@]}" +} + +print_available_widgets() { + local include_all="false" + local json="false" + + while (( $# > 0 )); do + case "$1" in + --all) + include_all="true" + ;; + --json) + json="true" + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown list option: $1" + ;; + esac + shift + done + + if [[ $json == "true" ]]; then + available_widgets_json "$include_all" + return + fi + + available_widgets_json "$include_all" | jq -r ' + .[] | + [ + .id, + (if .inBar and .allowMultiple then "addable*" elif .inBar then "in-bar" else "addable" end), + .category, + .name, + .description + ] | @tsv + ' | awk -F '\t' ' + BEGIN { printf "%-28s %-10s %-14s %-22s %s\n", "ID", "STATUS", "CATEGORY", "NAME", "DESCRIPTION" } + { printf "%-28s %-10s %-14s %-22s %s\n", $1, $2, $3, $4, $5 } + ' +} + +choose_widget() { + local widgets + local options=() + local selected="" + + widgets=$(available_widgets_json false) + + if ! jq -e 'length > 0' <<<"$widgets" >/dev/null; then + fail "no addable bar widgets found" + fi + + mapfile -t options < <(jq -r ' + .[] | + [.id, .name, .category, .description] | @tsv + ' <<<"$widgets" | awk -F '\t' '{ printf "%-28s %-22s [%s] %s\n", $1, $2, $3, $4 }') + + if [[ -t 0 || -t 2 ]]; then + if omarchy-cmd-present gum; then + selected=$(printf '%s\n' "${options[@]}" | gum filter --header "Add bar widget" --placeholder "Search addable widgets..." --limit 1 --height 14) || return 1 + elif omarchy-cmd-present fzf; then + selected=$(printf '%s\n' "${options[@]}" | fzf --prompt "Add bar widget > " --height 40% --layout reverse --border) || return 1 + else + print_available_widgets >&2 + fail "gum or fzf is required for interactive widget selection" + fi + else + print_available_widgets >&2 + fail "widget id is required; use list to see addable widgets" + fi + + selected="${selected%%[[:space:]]*}" + [[ -n $selected ]] || return 1 + printf '%s\n' "$selected" +} + +choose_removal() { + local entries + local widgets + local options=() + local selected="" + local location="" + local section="" + local index="" + + entries=$(current_bar_entries_json) + widgets=$(available_widgets_json true) + + if ! jq -e 'length > 0' <<<"$entries" >/dev/null; then + fail "no bar widgets are currently configured" + fi + + mapfile -t options < <(jq -r --argjson widgets "$widgets" ' + def widget_meta($id): first($widgets[]? | select(.id == $id)) // {}; + + .[] as $entry | + (widget_meta($entry.id)) as $meta | + [ + "\($entry.section)[\($entry.index)]", + $entry.id, + ($meta.name // $entry.id), + ($meta.category // "") + ] | @tsv + ' <<<"$entries" | awk -F '\t' '{ printf "%-10s %-28s %-22s %s\n", $1, $2, $3, $4 }') + + if [[ -t 0 || -t 2 ]]; then + if omarchy-cmd-present gum; then + selected=$(printf '%s\n' "${options[@]}" | gum filter --header "Remove bar widget" --placeholder "Search current widgets..." --limit 1 --height 14) || return 1 + elif omarchy-cmd-present fzf; then + selected=$(printf '%s\n' "${options[@]}" | fzf --prompt "Remove bar widget > " --height 40% --layout reverse --border) || return 1 + else + jq -r '.[] | "\(.section)[\(.index)]\t\(.id)"' <<<"$entries" >&2 + fail "gum or fzf is required for interactive widget selection" + fi + else + jq -r '.[] | "\(.section)[\(.index)]\t\(.id)"' <<<"$entries" >&2 + fail "widget id is required; use show to see current widgets" + fi + + location="${selected%%[[:space:]]*}" + section="${location%%[*}" + index="${location#*[}" + index="${index%]}" + + [[ $section =~ ^(left|center|right)$ ]] || return 1 + [[ $index =~ ^[0-9]+$ ]] || return 1 + printf '%s\t%s\n' "$section" "$index" +} + command="${1:-}" case "$command" in - show) + show|current) if (( $# != 1 )); then usage exit 1 @@ -42,13 +256,21 @@ case "$command" in jq '.bar // {}' "$(source_file)" ;; + list|available|options) + shift + print_available_widgets "$@" + ;; add) - if (( $# < 2 || $# > 3 )); then + if (( $# > 3 )); then usage exit 1 fi - plugin="${2:-}" + if (( $# == 1 )); then + plugin=$(choose_widget) || exit 1 + else + plugin="${2:-}" + fi section="${3:-right}" [[ -n $plugin ]] || fail "plugin is required" @@ -91,7 +313,7 @@ case "$command" in mv "$tmp" "$CONFIG_FILE" trap - EXIT - omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true + refresh_shell_config ;; position) if (( $# != 2 )); then @@ -120,6 +342,7 @@ case "$command" in mv "$tmp" "$CONFIG_FILE" trap - EXIT + refresh_shell_config ;; transparent) if (( $# != 2 )); then @@ -148,16 +371,26 @@ case "$command" in mv "$tmp" "$CONFIG_FILE" trap - EXIT + refresh_shell_config ;; - drop) - if (( $# != 2 )); then + drop|remove|rm) + if (( $# > 2 )); then usage exit 1 fi - plugin="${2:-}" + plugin="" + target_section="" + target_index="" - [[ -n $plugin ]] || fail "plugin is required" + if (( $# == 1 )); then + removal=$(choose_removal) || exit 1 + target_section="${removal%%$'\t'*}" + target_index="${removal#*$'\t'}" + else + plugin="${2:-}" + [[ -n $plugin ]] || fail "plugin is required" + fi mkdir -p "$(dirname "$CONFIG_FILE")" @@ -165,24 +398,45 @@ case "$command" in tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT - jq --arg plugin "$plugin" ' - def object_or_empty: if type == "object" then . else {} end; - def array_or_empty: if type == "array" then . else [] end; - def entry_id: if type == "object" then (.id // "" | tostring) else tostring end; + if [[ -n $target_section ]]; then + jq --arg section "$target_section" --argjson index "$target_index" ' + def object_or_empty: if type == "object" then . else {} end; + def array_or_empty: if type == "array" then . else [] end; - object_or_empty - | .version = 1 - | .bar = (.bar | object_or_empty) - | .bar.layout = (.bar.layout | object_or_empty) - | .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin))) - | .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin))) - | .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin))) - | .plugins = (.plugins | array_or_empty) - ' "$source" >"$tmp" + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | .bar.layout = (.bar.layout | object_or_empty) + | .bar.layout.left = (.bar.layout.left | array_or_empty) + | .bar.layout.center = (.bar.layout.center | array_or_empty) + | .bar.layout.right = (.bar.layout.right | array_or_empty) + | if (.bar.layout[$section] | length) <= $index then + error("no widget at " + $section + "[" + ($index | tostring) + "]") + else + .bar.layout[$section] = (.bar.layout[$section][0:$index] + .bar.layout[$section][($index + 1):]) + end + | .plugins = (.plugins | array_or_empty) + ' "$source" >"$tmp" + else + jq --arg plugin "$plugin" ' + def object_or_empty: if type == "object" then . else {} end; + def array_or_empty: if type == "array" then . else [] end; + def entry_id: if type == "object" then (.id // "" | tostring) else tostring end; + + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | .bar.layout = (.bar.layout | object_or_empty) + | .bar.layout.left = (.bar.layout.left | array_or_empty | map(select(entry_id != $plugin))) + | .bar.layout.center = (.bar.layout.center | array_or_empty | map(select(entry_id != $plugin))) + | .bar.layout.right = (.bar.layout.right | array_or_empty | map(select(entry_id != $plugin))) + | .plugins = (.plugins | array_or_empty) + ' "$source" >"$tmp" + fi mv "$tmp" "$CONFIG_FILE" trap - EXIT - omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true + refresh_shell_config ;; *) usage diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 5f13b987..0afbfce1 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -61,6 +61,7 @@ individual plugins (`bar`, `image-selector`, …). | `toggle ` | summon if closed, hide if open | | `call ` | call an already-loaded plugin | | `rescanPlugins` | re-walk plugin dirs | +| `reloadConfig` | reload shell.json | | `setPluginEnabled <"true"\|…>` | flip enabled bit | | `listPlugins` | JSON of every discovered plugin | diff --git a/shell/README.md b/shell/README.md index a8bf2325..5eec0fee 100644 --- a/shell/README.md +++ b/shell/README.md @@ -34,6 +34,7 @@ shell/ network/ power/ weather/ + model-usage/ services/ battery/ idle/ @@ -132,6 +133,7 @@ running a separate Quickshell instance. | `toggle ` | — | summon if closed, hide if open | | `call ` | string | call a method on an already-loaded plugin | | `rescanPlugins` | — | re-walk plugin dirs and pick up new/changed manifests | +| `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` | | `setPluginEnabled ` | — | flip the persisted enabled bit (see note) | | `listPlugins` | JSON | every discovered plugin (id, name, kinds, enabled) | diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 95968702..7adcb52e 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -144,6 +144,18 @@ HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar show | jq -e ' ' >/dev/null pass "shell config shows only bar json" +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar list --json | jq -e ' + any(.[]; .id == "omarchy.system-stats" and .addable == true and .inBar == false) and + all(.[]; .id != "omarchy.tailscale") +' >/dev/null +pass "shell config lists addable bar widgets" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar list --json --all | jq -e ' + any(.[]; .id == "omarchy.tailscale" and .inBar == true and .addable == false) and + any(.[]; .id == "omarchy.indicators" and .addable == true) +' >/dev/null +pass "shell config list --all includes current widget status" + HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar position bottom jq -e ' .bar.position == "bottom" and @@ -168,6 +180,13 @@ jq -e ' ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null pass "shell config drops widgets from any section" +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-config-shell-bar remove local.center +jq -e ' + def ids: map(.id // .); + .bar.layout.center | ids == ["omarchy.clock", "omarchy.weather"] +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "shell config removes widgets with remove alias" + cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' { "version": 1, diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index 675f89ed..0b9515b8 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -192,3 +192,31 @@ jq -e ' } pass "runtime geometry places visible update between weather and indicators" + +HOME="$test_home" OMARCHY_PATH="$test_root" PATH="$ROOT/bin:$PATH" "$ROOT/bin/omarchy-config-shell-bar" remove omarchy.audio + +for _ in {1..80}; do + shell_config=$(shell_ipc shell listShellConfig 2>/dev/null || true) + geometry=$(shell_ipc shell debugBarGeometry 2>/dev/null || true) + if jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.audio")' <<<"$shell_config" >/dev/null 2>&1 && \ + jq -e 'all(.[]; .id != "omarchy.audio")' <<<"$geometry" >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + fail_with_log "test shell exited before reloaded bar geometry settled" + fi + sleep 0.1 +done + +jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.audio")' <<<"$shell_config" >/dev/null || { + printf 'Shell config after reload:\n%s\n' "$shell_config" | jq . >&2 + fail_with_log "config shell bar remove reloads shell config" +} + +jq -e 'all(.[]; .id != "omarchy.audio")' <<<"$geometry" >/dev/null || { + printf 'Geometry after reload:\n' >&2 + jq . <<<"$geometry" >&2 + fail_with_log "runtime bar layout updates after shell config reload" +} + +pass "config shell bar remove reloads shell config and updates bar layout"