MENU

Bashで矢印キーを使ったインタラクティブメニューが止まるときの対処法

目次

はじめに

Bash スクリプトでインタラクティブなメニューを作りたいとき、
read コマンドを使ってキー入力を処理する方法があります。

しかし、実際に矢印キーで操作できるメニューを作ってみると、
上下キーを押した瞬間にスクリプトが終了してしまうことがあります。

この記事では、この現象の再現方法と原因、そして解決方法を紹介します。

問題の再現

以下のサンプルコードを実行して、上下キーを押してみてください。

ファイル名: test_menu_bug.sh

#!/usr/bin/env bash
set -e
set -o pipefail

# 再現用:結果を echo で返す版(nameref 不使用)
# ※ read -t のタイムアウトや、呼び出し側のコマンド置換が set -e と噛み合って落ちます
capture_keys() {
  local a b c
  IFS= read -rsn1 a

  if [[ -z $a ]]; then
    echo "Enter"
    return
  fi

  if [[ $a != $'\x1b' ]]; then
    echo "$a"
    return
  fi

  # ★ NGポイント1: タイムアウトして終了コード1になる可能性(set -e で即終了)
  read -rsn1 -t 0.05 b
  if [[ $b == "[" || $b == "O" ]]; then
    # ★ NGポイント2: ここも同様にタイムアウトで終了コード1 → 即終了
    read -rsn1 -t 0.05 c
    case "$c" in
      A) echo "Up" ;;
      B) echo "Down" ;;
      C) echo "Right" ;;
      D) echo "Left" ;;
      *) echo "Unknown" ;;
    esac
  else
    echo "Escape"
  fi
}

show_test_menu() {
  echo "テスト用メニュー(矢印キーで止まる再現コード)"
  echo "矢印キーで選択、Enterで決定"
  echo ""

  local options=("オプション1" "オプション2")
  local selected=0
  local key=""

  while true; do
    for i in "${!options[@]}"; do
      if [[ $i -eq $selected ]]; then
        printf "\e[32m> %s\e[0m\n" "${options[$i]}"
      else
        printf "  %s\n" "${options[$i]}"
      fi
    done

    # ★ NGポイント3: コマンド置換 $(...) 内で read 実行 → set -e の例外規則が効かず落ちやすい
    key="$(capture_keys)"

    # 表示クリア
    for ((i=0; i<${#options[@]}; i++)); do
      printf "\e[1A\e[2K"
    done

    case "$key" in
      Up)
        # ★ NGポイント4: 単独 (( ... )) は式が0だと終了コード1 → set -e で終了することがある
        ((selected--))
        if [[ $selected -lt 0 ]]; then
          selected=$((${#options[@]} - 1))
        fi
        ;;
      Down)
        ((selected++))
        if [[ $selected -ge ${#options[@]} ]]; then
          selected=0
        fi
        ;;
      Enter)
        for i in "${!options[@]}"; do
          if [[ $i -eq $selected ]]; then
            printf "\e[32m> %s\e[0m\n" "${options[$i]}"
          else
            printf "  %s\n" "${options[$i]}"
          fi
        done
        break
        ;;
      *)
        :
        ;;
    esac
  done

  echo ""
  echo "選択されたオプション: ${options[$selected]}"
}

show_test_menu

症状

  • 上下矢印キーを押すと「選択されたオプション: …」が表示される前に終了してしまう
  • set -e を外すと正常に動作する

原因

この現象の原因は、set -eread -t の組み合わせにあります。

  • set -e(errexit)は「終了コードが非ゼロのコマンドでスクリプトを強制終了」するオプション
  • read -t は「タイムアウトしたときに終了コード1を返す」

つまり、矢印キーの入力を待っている間に read -t がタイムアウトすると、
正常なタイムアウト処理なのにエラー扱いされてスクリプトが終了してしまうわけです

解決方法

ポイント

  • エスケープシーケンスが終わらなかった場合でも「Escape」として処理すれば止まらない
  • read -t を必ず if ...; then ... fi でラップして「タイムアウトを正常系として扱う」

修正版コード(成功例)

ファイル名: test_menu_fixed.sh

#!/usr/bin/env bash
set -e
set -o pipefail

capture_keys() {
  local -n __out=$1
  __out=""

  local a b c
  if ! IFS= read -rsn1 a; then
    __out=""
    return 0
  fi

  if [[ -z $a ]]; then
    __out="Enter"
    return 0
  fi

  if [[ $a != $'\x1b' ]]; then
    __out="$a"
    return 0
  fi

  if IFS= read -rsn1 -t 0.05 b; then
    if [[ $b == "[" || $b == "O" ]]; then
      if IFS= read -rsn1 -t 0.05 c; then
        case "$c" in
          A) __out="Up" ;;
          B) __out="Down" ;;
          C) __out="Right" ;;
          D) __out="Left" ;;
          *) __out="Unknown" ;;
        esac
      else
        __out="Escape"
      fi
    else
      __out="Escape"
    fi
  else
    __out="Escape"
  fi

  return 0
}

show_test_menu() {
  echo "テスト用メニュー"
  echo "矢印キーで選択、Enterで決定"
  echo ""

  local options=("オプション1" "オプション2")
  local selected=0
  local key=""

  while true; do
    for i in "${!options[@]}"; do
      if [[ $i -eq $selected ]]; then
        printf "\e[32m> %s\e[0m\n" "${options[$i]}"
      else
        printf "  %s\n" "${options[$i]}"
      fi
    done

    capture_keys key

    for ((i=0; i<${#options[@]}; i++)); do
      printf "\e[1A\e[2K"
    done

    case "$key" in
      Up)
        selected=$((selected - 1))
        if [[ $selected -lt 0 ]]; then
          selected=$((${#options[@]} - 1))
        fi
        ;;
      Down)
        selected=$((selected + 1))
        if [[ $selected -ge ${#options[@]} ]]; then
          selected=0
        fi
        ;;
      Enter)
        for i in "${!options[@]}"; do
          if [[ $i -eq $selected ]]; then
            printf "\e[32m> %s\e[0m\n" "${options[$i]}"
          else
            printf "  %s\n" "${options[$i]}"
          fi
        done
        break
        ;;
      *)
        :
        ;;
    esac
  done

  echo ""
  echo "選択されたオプション: ${options[$selected]}"
}

show_test_menu

実行結果

  • 上矢印キー → Up
  • 下矢印キー → Down
  • Enterキー → Enter
  • ESCキー → Escape

スクリプトも最後まで実行され、最後に「選択されたオプション: …」が表示されます。

おまけ:便利Tips

エラーを常に表示する trap ERR

set -e でスクリプトが落ちると「なぜ?」と原因がわかりにくい場合があります。
そんなときは冒頭に trap ERR を仕込むと便利です。

これでエラー発生時に 赤字でコマンドと終了コードを表示してくれるので、デバッグが楽になります。

trap 'echo -e "\e[31mERR: \"$BASH_COMMAND\" exited with code=$?\e[0m" >&2' ERR

まとめ

  • 症状: 矢印キーを押すと「選択されたオプション」が出る前にスクリプトが終了する
  • 原因: set -eread -t の組み合わせ。read -t のタイムアウトが終了コード1を返す → set -e で即終了
  • 解決方法: if read ...; then ... else ... fi でラップし、タイムアウトを正常処理扱いにする
  • 結果: 修正版では Up/Down/Enter/Escape が正常に判定され、最後に「選択されたオプション: …」が表示される
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次