From e7b7eb2d9539a48e094a0b462b4bda3a6ddd5716 Mon Sep 17 00:00:00 2001 From: Felipe Puentes Date: Wed, 24 Jun 2026 11:10:44 -0500 Subject: [PATCH 1/2] feat: add Rectangle with conflict handling --- README.md | 11 ++++-- components/42-rectangle.sh | 19 ++++++++++ install.sh | 48 +++++++++++++++++++++++-- lib/core.sh | 74 ++++++++++++++++++++++++++++++++++++-- lib/ui.sh | 2 +- sync.sh | 4 +-- tests/installer_test.sh | 57 +++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 components/42-rectangle.sh diff --git a/README.md b/README.md index 14e7875..5c8165a 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ cd ~/power_conf ``` The installer opens an interactive terminal menu. Use the arrow keys to move, -**Space** to select or deselect software, and **Enter** to continue. Everything -is selected initially, preserving the original one-command setup. +**Space** to select or deselect software, and **Enter** to continue. Recommended +apps are selected initially. The interactive flow starts with a `power_mac` welcome screen and a compact preparation log while it discovers modules, restores saved preferences, and @@ -38,7 +38,7 @@ table for selection. ### Non-interactive usage ```bash -# Install everything +# Install all compatible components (recommended alternatives win) ./install.sh --all # Install only selected component bundles @@ -68,8 +68,13 @@ Run `./install.sh --help` for the complete component list. | Tool | Description | Install | | --- | --- | --- | | 🌌 **AeroSpace** | i3-like tiling window manager for macOS | `brew install --cask nikitabobko/tap/aerospace` | +| ▭ **Rectangle** | Keyboard-driven window snapping | `brew install --cask rectangle` | | 🔄 **AltTab** | Windows-style alt-tab app switcher | `brew install --cask alt-tab` | +> AeroSpace and Rectangle are alternatives. The installer prevents selecting +> both and keeps only the most recently selected one in its saved state. When +> switching, it reminds you to quit or disable the previous window manager. + ### 🛠️ Productivity & Utilities | Tool | Description | Install | diff --git a/components/42-rectangle.sh b/components/42-rectangle.sh new file mode 100644 index 0000000..085f576 --- /dev/null +++ b/components/42-rectangle.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +component_define \ + "rectangle" \ + "Rectangle" \ + "Keyboard window snapping; choose this or AeroSpace, not both" \ + "Window Management" \ + "false" \ + "false" \ + "" \ + "cask" \ + "rectangle" \ + "" \ + "" \ + "" \ + "" \ + "" + +component_conflict "rectangle" "aerospace" diff --git a/install.sh b/install.sh index b55448a..6ccf7cd 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ Usage: ./install.sh --components ID,ID,... [--tmux-style top|bottom] [--dry-run] Options: - --all Install every selectable component. + --all Install all compatible components, preferring defaults. --components LIST Install a comma-separated list of component IDs. --tmux-style STYLE Use the top or bottom Tmux status bar (default: bottom). --dry-run Show the resolved work without changing the machine. @@ -128,14 +128,16 @@ SELECTED_COMPONENTS=() if [ "$SELECTION_MODE" = all ]; then while IFS= read -r id; do [ -n "$id" ] && SELECTED_COMPONENTS+=("$id") - done < <(pm_selectable_component_ids) + done < <(pm_compatible_component_ids) elif [ "$SELECTION_MODE" = components ]; then pm_split_csv "$COMPONENTS_ARGUMENT" SELECTED_COMPONENTS else pm_ui_selection_header menu_options=() + default_components=() for ((i = 0; i < ${#COMPONENT_IDS[@]}; i++)); do [ "${COMPONENT_HIDDEN[$i]}" = false ] || continue + [ "${COMPONENT_DEFAULTS[$i]}" = false ] || default_components+=("${COMPONENT_IDS[$i]}") printf -v menu_label " %-19s %-18s %s:%s" \ "${COMPONENT_CATEGORIES[$i]}" \ "${COMPONENT_LABELS[$i]}" \ @@ -146,7 +148,7 @@ else selection_output="$( gum choose \ --no-limit \ - --selected "*" \ + --selected "$(pm_join_by , "${default_components[@]}")" \ --height 14 \ --label-delimiter ":" \ "${menu_options[@]}" @@ -159,8 +161,48 @@ else done <<< "$selection_output" fi +if [ "$SELECTION_MODE" = interactive ]; then + for ((i = 0; i < ${#COMPONENT_CONFLICT_IDS[@]}; i++)); do + conflict_id="${COMPONENT_CONFLICT_IDS[$i]}" + conflict_target="${COMPONENT_CONFLICT_TARGETS[$i]}" + if pm_array_contains "$conflict_id" "${SELECTED_COMPONENTS[@]}" && + pm_array_contains "$conflict_target" "${SELECTED_COMPONENTS[@]}"; then + conflict_label="$(pm_component_field "$conflict_id" COMPONENT_LABELS)" + target_label="$(pm_component_field "$conflict_target" COMPONENT_LABELS)" + pm_warn "$conflict_label and $target_label overlap and should not run together." + keep_component="$( + gum choose \ + --header "Choose one window manager to keep" \ + "$conflict_id" \ + "$conflict_target" + )" || { + pm_warn "Installation cancelled" + exit 0 + } + filtered_components=() + for id in "${SELECTED_COMPONENTS[@]}"; do + if [ "$id" = "$conflict_id" ] || [ "$id" = "$conflict_target" ]; then + [ "$id" = "$keep_component" ] && filtered_components+=("$id") + else + filtered_components+=("$id") + fi + done + SELECTED_COMPONENTS=("${filtered_components[@]}") + pm_ok "Keeping $(pm_component_field "$keep_component" COMPONENT_LABELS)" + fi + done +fi + pm_validate_selected_ids "${SELECTED_COMPONENTS[@]}" +for id in "${SELECTED_COMPONENTS[@]}"; do + for saved_id in "${PM_STATE_COMPONENTS[@]}"; do + if pm_components_conflict "$id" "$saved_id"; then + pm_warn "$(pm_component_field "$id" COMPONENT_LABELS) replaces $(pm_component_field "$saved_id" COMPONENT_LABELS) in the saved setup. Quit or disable $(pm_component_field "$saved_id" COMPONENT_LABELS) to avoid overlapping window controls." + fi + done +done + if pm_array_contains "tmux" "${SELECTED_COMPONENTS[@]}" && [ "$SELECTION_MODE" = interactive ] && [ "$TMUX_STYLE_SET" = false ]; then TMUX_STYLE="$(gum choose --selected "$TMUX_STYLE" --header "Choose the Tmux status bar position" bottom top)" || { pm_warn "Installation cancelled" diff --git a/lib/core.sh b/lib/core.sh index cccb0a2..1012108 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -92,6 +92,8 @@ COMPONENT_INSTALL_HOOKS=() COMPONENT_SYNC_HOOKS=() COMPONENT_DRY_RUN_HOOKS=() COMPONENT_POST_HOOKS=() +COMPONENT_CONFLICT_IDS=() +COMPONENT_CONFLICT_TARGETS=() # component_define id label description category default hidden dependencies # kind package configs install_hook sync_hook dry_run_hook post_hook @@ -113,6 +115,12 @@ component_define() { COMPONENT_POST_HOOKS+=("${14}") } +component_conflict() { + [ "$#" -eq 2 ] || pm_die "component_conflict expected 2 arguments, received $#" + COMPONENT_CONFLICT_IDS+=("$1") + COMPONENT_CONFLICT_TARGETS+=("$2") +} + pm_component_index() { local id="$1" local i @@ -221,6 +229,16 @@ pm_validate_registry() { check_ids+=("$id") done pm_resolve_components "${check_ids[@]}" >/dev/null + + for ((i = 0; i < ${#COMPONENT_CONFLICT_IDS[@]}; i++)); do + id="${COMPONENT_CONFLICT_IDS[$i]}" + other="${COMPONENT_CONFLICT_TARGETS[$i]}" + [ "$id" != "$other" ] || pm_die "Component '$id' cannot conflict with itself" + pm_component_index "$id" >/dev/null || + pm_die "Conflict references unknown component '$id'" + pm_component_index "$other" >/dev/null || + pm_die "Component '$id' conflicts with unknown component '$other'" + done } PM_RESOLVED_COMPONENTS=() @@ -272,14 +290,58 @@ pm_default_component_ids() { done } +pm_compatible_component_ids() { + local desired_default i id selected conflict_found + local compatible=() + for desired_default in true false; do + for ((i = 0; i < ${#COMPONENT_IDS[@]}; i++)); do + [ "${COMPONENT_HIDDEN[$i]}" = false ] || continue + [ "${COMPONENT_DEFAULTS[$i]}" = "$desired_default" ] || continue + id="${COMPONENT_IDS[$i]}" + conflict_found=false + for selected in "${compatible[@]}"; do + if pm_components_conflict "$id" "$selected"; then + conflict_found=true + break + fi + done + [ "$conflict_found" = true ] || compatible+=("$id") + done + done + printf '%s\n' "${compatible[@]}" +} + pm_validate_selected_ids() { - local id hidden + local id hidden i conflict_id conflict_target [ "$#" -gt 0 ] || pm_die "No components selected" for id in "$@"; do pm_component_index "$id" >/dev/null || pm_die "Unknown component '$id'" hidden="$(pm_component_field "$id" COMPONENT_HIDDEN)" [ "$hidden" = false ] || pm_die "Component '$id' is internal and cannot be selected directly" done + for ((i = 0; i < ${#COMPONENT_CONFLICT_IDS[@]}; i++)); do + conflict_id="${COMPONENT_CONFLICT_IDS[$i]}" + conflict_target="${COMPONENT_CONFLICT_TARGETS[$i]}" + if pm_array_contains "$conflict_id" "$@" && + pm_array_contains "$conflict_target" "$@"; then + pm_die "$(pm_component_field "$conflict_id" COMPONENT_LABELS) and $(pm_component_field "$conflict_target" COMPONENT_LABELS) cannot be selected together. Choose one window manager." + fi + done +} + +pm_components_conflict() { + local first="$1" + local second="$2" + local i conflict_id conflict_target + for ((i = 0; i < ${#COMPONENT_CONFLICT_IDS[@]}; i++)); do + conflict_id="${COMPONENT_CONFLICT_IDS[$i]}" + conflict_target="${COMPONENT_CONFLICT_TARGETS[$i]}" + if { [ "$conflict_id" = "$first" ] && [ "$conflict_target" = "$second" ]; } || + { [ "$conflict_id" = "$second" ] && [ "$conflict_target" = "$first" ]; }; then + return 0 + fi + done + return 1 } pm_requires_homebrew() { @@ -529,7 +591,15 @@ pm_save_state() { local id if pm_load_state; then for id in "${PM_STATE_COMPONENTS[@]}"; do - pm_component_index "$id" >/dev/null 2>&1 && pm_append_unique merged "$id" + pm_component_index "$id" >/dev/null 2>&1 || continue + local selected conflict_found=false + for selected in "$@"; do + if pm_components_conflict "$id" "$selected"; then + conflict_found=true + break + fi + done + [ "$conflict_found" = true ] || pm_append_unique merged "$id" done fi for id in "$@"; do diff --git a/lib/ui.sh b/lib/ui.sh index 01bf16c..f8e7183 100644 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -52,7 +52,7 @@ pm_ui_preparing_header() { pm_ui_selection_header() { printf '\n%sChoose your setup%s\n' "$PM_BOLD" "$PM_RESET" - printf 'Space toggles • Enter continues • Everything starts selected\n\n' + printf 'Space toggles • Enter continues • Recommended apps start selected\n\n' printf ' %-19s %-18s %s\n' "CATEGORY" "APP" "DESCRIPTION" printf ' %-19s %-18s %s\n' "───────────────────" "──────────────────" "────────────────────────────" } diff --git a/sync.sh b/sync.sh index 911d426..3a139a3 100755 --- a/sync.sh +++ b/sync.sh @@ -19,7 +19,7 @@ If no state exists, it detects configs linked by older power_mac releases and migrates them after a successful sync. Options: - --all Sync every selectable component configuration. + --all Sync all compatible components, preferring defaults. --components LIST Sync a comma-separated list of component IDs. --dry-run Show config changes without writing them. --help Show this help. @@ -65,7 +65,7 @@ case "$SELECTION_MODE" in all) while IFS= read -r id; do [ -n "$id" ] && SELECTED_COMPONENTS+=("$id") - done < <(pm_selectable_component_ids) + done < <(pm_compatible_component_ids) ;; components) pm_split_csv "$COMPONENTS_ARGUMENT" SELECTED_COMPONENTS diff --git a/tests/installer_test.sh b/tests/installer_test.sh index e5d721b..e127bf7 100755 --- a/tests/installer_test.sh +++ b/tests/installer_test.sh @@ -100,6 +100,7 @@ if [ "$1" = choose ]; then fi case "$*" in *"Tmux status bar"*) printf '%s\n' "${POWER_MAC_GUM_TMUX_STYLE:-bottom}" ;; + *"Choose one window manager"*) printf '%s\n' "${POWER_MAC_GUM_WINDOW_MANAGER:-aerospace}" ;; *) printf '%s\n' "${POWER_MAC_GUM_SELECTION:-neovim}" ;; esac fi @@ -174,6 +175,41 @@ else fail "subsequent installs merge saved state" fi +home="$TEST_TMP/home-window-manager-conflict" +mkdir -p "$home" +output="$(run_install "$home" --components rectangle --dry-run)" +if assert_contains "$output" "Homebrew cask rectangle"; then + pass "Rectangle is available as a Homebrew cask" +else + fail "Rectangle is available as a Homebrew cask" +fi + +if run_install "$home" --components aerospace,rectangle --dry-run >/dev/null 2>&1; then + fail "conflicting window managers are rejected" +else + pass "conflicting window managers are rejected" +fi + +output="$(run_install "$home" --all --dry-run)" +if assert_contains "$output" "AeroSpace" && + ! assert_contains "$output" "Homebrew cask rectangle" >/dev/null 2>&1; then + pass "all mode prefers the recommended window manager" +else + fail "all mode prefers the recommended window manager" +fi + +home="$TEST_TMP/home-window-manager-switch" +mkdir -p "$home" +if run_install "$home" --components aerospace >/dev/null && + output="$(run_install "$home" --components rectangle)" && + assert_contains "$output" "Quit or disable AeroSpace" && + assert_file_contains "$home/.config/power_mac/state" "components=rectangle" && + ! assert_file_contains "$home/.config/power_mac/state" "aerospace" >/dev/null 2>&1; then + pass "selecting Rectangle replaces AeroSpace in saved state" +else + fail "selecting Rectangle replaces AeroSpace in saved state" +fi + state_home="$TEST_TMP/home-state-sync" mkdir -p "$state_home/.config/power_mac" printf 'components=wezterm\ntmux_style=bottom\n' > "$state_home/.config/power_mac/state" @@ -304,6 +340,27 @@ else fail "interactive welcome UI leads into chosen component" fi +home="$TEST_TMP/home-interactive-conflict" +mkdir -p "$home" +interactive_output="$(HOME="$home" \ + PATH="$TEST_TMP/fake-bin:$PATH" \ + POWER_MAC_ALLOW_NON_DARWIN=true \ + POWER_MAC_ALLOW_NON_TTY_INTERACTIVE=true \ + POWER_MAC_SKIP_REPO_HOOKS=true \ + POWER_MAC_UI_DELAY=0 \ + POWER_MAC_TEST_LOG="$TEST_TMP/commands.log" \ + POWER_MAC_GUM_SELECTION=$'aerospace\nrectangle' \ + POWER_MAC_GUM_WINDOW_MANAGER=rectangle \ + "$ROOT/install.sh")" +if assert_contains "$interactive_output" "should not run together" && + assert_contains "$interactive_output" "Keeping Rectangle" && + assert_file_contains "$home/.config/power_mac/state" "components=rectangle" && + ! assert_file_contains "$home/.config/power_mac/state" "aerospace" >/dev/null 2>&1; then + pass "interactive conflicts warn and ask which component to keep" +else + fail "interactive conflicts warn and ask which component to keep" +fi + home="$TEST_TMP/home-cancel" mkdir -p "$home" if HOME="$home" \ From c4991e702d3d65176ab79ee7953ef526bc02052b Mon Sep 17 00:00:00 2001 From: Felipe Puentes Date: Wed, 24 Jun 2026 11:24:37 -0500 Subject: [PATCH 2/2] fix: preselect Gum options by label --- install.sh | 14 ++++++------ tests/installer_test.sh | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/install.sh b/install.sh index 6ccf7cd..6375314 100755 --- a/install.sh +++ b/install.sh @@ -134,21 +134,21 @@ elif [ "$SELECTION_MODE" = components ]; then else pm_ui_selection_header menu_options=() - default_components=() + default_menu_labels=() for ((i = 0; i < ${#COMPONENT_IDS[@]}; i++)); do [ "${COMPONENT_HIDDEN[$i]}" = false ] || continue - [ "${COMPONENT_DEFAULTS[$i]}" = false ] || default_components+=("${COMPONENT_IDS[$i]}") - printf -v menu_label " %-19s %-18s %s:%s" \ + menu_description="${COMPONENT_DESCRIPTIONS[$i]//,/;}" + printf -v menu_label " %-19s %-18s %s" \ "${COMPONENT_CATEGORIES[$i]}" \ "${COMPONENT_LABELS[$i]}" \ - "${COMPONENT_DESCRIPTIONS[$i]}" \ - "${COMPONENT_IDS[$i]}" - menu_options+=("$menu_label") + "$menu_description" + menu_options+=("${menu_label}:${COMPONENT_IDS[$i]}") + [ "${COMPONENT_DEFAULTS[$i]}" = false ] || default_menu_labels+=("$menu_label") done selection_output="$( gum choose \ --no-limit \ - --selected "$(pm_join_by , "${default_components[@]}")" \ + --selected "$(pm_join_by , "${default_menu_labels[@]}")" \ --height 14 \ --label-delimiter ":" \ "${menu_options[@]}" diff --git a/tests/installer_test.sh b/tests/installer_test.sh index e127bf7..cb42e48 100755 --- a/tests/installer_test.sh +++ b/tests/installer_test.sh @@ -98,6 +98,37 @@ if [ "$1" = choose ]; then if [ "${POWER_MAC_GUM_CANCEL:-false}" = true ]; then exit 1 fi + simulate_defaults=false + case "$*" in + *"--label-delimiter"*) simulate_defaults="${POWER_MAC_GUM_SELECT_DEFAULTS:-false}" ;; + esac + if [ "$simulate_defaults" = true ]; then + selected="" + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --selected) + selected="$2" + shift + ;; + *:*) + label="${1%:*}" + value="${1##*:}" + old_ifs="$IFS" + IFS=',' + for selected_label in $selected; do + if [ "$selected_label" = "$label" ]; then + printf '%s\n' "$value" + break + fi + done + IFS="$old_ifs" + ;; + esac + shift + done + exit 0 + fi case "$*" in *"Tmux status bar"*) printf '%s\n' "${POWER_MAC_GUM_TMUX_STYLE:-bottom}" ;; *"Choose one window manager"*) printf '%s\n' "${POWER_MAC_GUM_WINDOW_MANAGER:-aerospace}" ;; @@ -340,6 +371,25 @@ else fail "interactive welcome UI leads into chosen component" fi +home="$TEST_TMP/home-interactive-defaults" +mkdir -p "$home" +if HOME="$home" \ + PATH="$TEST_TMP/fake-bin:$PATH" \ + POWER_MAC_ALLOW_NON_DARWIN=true \ + POWER_MAC_ALLOW_NON_TTY_INTERACTIVE=true \ + POWER_MAC_SKIP_REPO_HOOKS=true \ + POWER_MAC_UI_DELAY=0 \ + POWER_MAC_TEST_LOG="$TEST_TMP/commands.log" \ + POWER_MAC_GUM_SELECT_DEFAULTS=true \ + "$ROOT/install.sh" >/dev/null && + assert_file_contains "$home/.config/power_mac/state" "shell" && + assert_file_contains "$home/.config/power_mac/state" "aerospace" && + ! assert_file_contains "$home/.config/power_mac/state" "rectangle" >/dev/null 2>&1; then + pass "interactive defaults use Gum display labels" +else + fail "interactive defaults use Gum display labels" +fi + home="$TEST_TMP/home-interactive-conflict" mkdir -p "$home" interactive_output="$(HOME="$home" \