マウスでターミナルにお絵描きをするシェルスクリプト

## 1. 概要
このスクリプトは、ターミナル上で動かしたマウスに合わせて文字を出力します(お絵描きができます)
- 左ボタンでドラッグ中は「*」を、右ボタンでドラッグすると「@」を出力します
- `c`キーで画面をクリア、`Ctrl+C`で終了  
- 画面の最下部にマウスの座標(行と列の位置)を表示します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276579/b475febc-9141-4711-af1c-2547d71a1fd1.png)

## 2. 動作環境
- Bash（Windowsであればgit bash等。Linuxへのssh接続でも動作します）
- xterm互換(マウスサポート有)ターミナル（GNOME Terminal、iTerm2 、Windows Terminalなど）

## 3. 予備知識

* `ターミナル`は、ユーザーがコマンドを入力する「画面のアプリケーション」（例: Windows Terminal, iTerm2など）
* `TTY（テレタイプ端末）`は、ターミナルソフトが接続する仮想デバイスで、入出力の中継点
* `ラインディシプリン（line discipline）`は、TTYの内部でキー入力の編集やエコーなどを行う処理層


### 3-1. `rawモード`と`カノニカルモード`について


通常（カノニカルモード）では、TTYのラインディシプリンが「1行入力が完了するまで、アプリケーションに渡さない」ように動作します。
シェルからコマンドを入力する際、ユーザーが Enterキーを押すまでは、シェルには文字が届かない、というイメージの動作です。

(※古い時代の`sh`はカノニカルモードで動作していましたが、bashはreadlineというライブラリを使い、ラインディシプリンと同様の操作＋α(補完処理等)を行っています）

このモードでは、**ラインディシプリンがキー入力を処理し、画面への表示（エコー）も担当します**。つまり、押したキーがターミナルに表示されるのは、TTYがカーネル内でその文字を受け取り、ラインディシプリンが echo 処理を行っているためです。

TTYをrawモードに切り替えると、ラインディシプリンによる行編集やバッファリングが無効になります。その結果、**キー入力は即座にアプリケーション（例: bashやvim）に通知され、アプリケーションが自ら表示や処理を行います**。

このため、vimのようなアプリケーションでは、rawモードを利用することで、キーを押した瞬間にモード切り替えや画面描画ができるようになります。

お絵描きシェルスクリプトでは`rawモード`に切り替えて、キーボード入力を処理しています

### 3-2. ターミナルのマウスサポートについて

マウスをサポートしているターミナルでは、キーボード入力と同様にマウス入力を受け取ることができます。
（マウス入力を表す、特別なエスケープシーケンスを受け取ります）

* マウス移動時、ターミナルが入力を受け取れるようにするため、エスケープシーケンスを送ります
```bash
# Any-event tracking を有効化 (\033[?1003h)
echo -ne "\033[?1003h"
```

※ `\033`は`ESC`の8進表記です(`\e`や`\x1b`と書いても同じ)

* ボタンのデータ形式
```bash
# ESC+[+M+ボタン情報+X座標+Y座標]
# 合計6byteのバイナリ形式
\x1b[Mbxy
```

- ESC：0x1B (エスケープシーケンス開始)
- [M：マウスレポート開始(固定)
- `<b>`：ボタンコード＋32  (以降、元の値に戻すには-32をする必要がある)
- `<x>`：列番号＋32  
- `<y>`：行番号＋32  

#### ボタンコード

| `<b>` の値 | 意味                                   |
|---------|------------------------------------------|
| `0`     | 左ボタン押下                              |
| `1`     | 中ボタン押下                              |
| `2`     | 右ボタン押下                              |
| `3`     | ボタン解放                                |
| `32`    | 左ボタン押下状態でマウス移動（ドラッグ）     |
| `34`    | 右ボタン押下状態でマウス移動（ドラッグ）     |


#### 列番号、行番号について

文字単位での位置を表しています（ドット単位の座標ではない）

```bash
# 例えば、10列(x)、20行(y)の位置で左クリックした場合、以下のデータを受け取る
# ボタン0 → <b> = 0+32 = 32(0x20)
# x=10 → <x> = 10+32 = 42(0x2A)
# y=20 → <y> = 20+32 = 52(0x34)
echo -ne "\x1b[M\x20\x2A\x34"
```
### 3-3. キーボードやマウス入力のデータをシェルスクリプトで受信するには？

下記のように記載することで、入力データを読み取り、変数にセットすることができます
```bash
IFS= read -rsn1 char
# 押されたキーを表示
echo $char
```
| 部分 | 意味 |
|------|------|
| `IFS=` | フィールド区切り文字（Internal Field Separator）を空にして、読み取り時に分割されるのを防ぐ |
| `read` | 入力を読み取って変数に代入するBashのビルトインコマンド |
| `-r` | バックスラッシュ（`\`）を特別扱いせず、そのまま読み取る |
| `-s` | 入力をエコーしない（画面に表示されないようにする） |
| `-n1` | 1文字だけ読み取る。`n1` の代わりに `n3` にすれば3文字読み取りになる |
| `char` | 読み取った文字を代入する変数名 |

## 4. スクリプト全体

```bash
#!/bin/bash
# ============================================================
# xterm互換ターミナルでAny-event tracking(マウスイベント追跡) を有効にし、
# 左ドラッグをするとその位置に "*"、右ドラッグは"#" を出力するサンプルスクリプト
# ============================================================

# stty設定を保存しておく
orig_stty=$(stty -g)

cleanup() {
  # マウストラッキングを解除
  echo -ne "\033[?1003l"
  # stty 設定を復元
  stty "$orig_stty"
  echo -e "\nマウスモードを解除しました。終了します。"
  exit
}

clear() {
  printf '\033[2J\033[H' # 画面をクリア＆カーソルを左上に移動
  printf "左ボタンを押しながら移動すると '*'、右ボタンだと'@'を出力します。\r\n"
  printf "終了するには[Ctrl+C]、画面をクリアするには [c] キーを押してください。\r\n"
}

trap cleanup INT TERM

# ------------------------------------------------------------
# 1. Any-event tracking を有効化 (\e[?1003h)
#    (マウスが移動しただけでもイベントを受け取るモード)
# ------------------------------------------------------------
echo -ne "\033[?1003h"

# ------------------------------------------------------------
# 2. ターミナルを raw モード & echo オフ
#    （入力をバッファせず、画面にも表示しない）
# ------------------------------------------------------------
stty raw -echo

# 画面をクリアして説明を表示する
clear

# ------------------------------------------------------------
# 3. メインループ: 入力バイトを解析
# ------------------------------------------------------------
while true; do
  IFS= read -rsn1 char

  # c キーでクリア
  if [[ $char == "c" ]]; then
    clear
  fi

  # ESC(0x1B,033) の場合、マウスイベントかどうかをチェック
  if [[ $char == $'\033' ]]; then
    # 続く2文字を読み取る
    IFS= read -rsn2 seq
    if [[ $seq == "[M" ]]; then
      # マウスデータ3バイト (ボタン情報, x, y)
      IFS= read -rsn3 mouse_data
      button_code=$(printf "%d" "'${mouse_data:0:1}")
      x_coord=$(printf "%d" "'${mouse_data:1:1}")
      y_coord=$(printf "%d" "'${mouse_data:2:1}")

      b=$(( button_code - 32 ))  # ボタンコード
      x=$(( x_coord - 32 )) # X座標
      y=$(( y_coord - 32 )) # &座標

      # ボタン判定
      case $b in
        0 | 32) # 左ボタンクリック、ドラッグしている場合
          # (x, y) に 赤色で"*" を描画
          printf "\033[31m\033[%d;%dH*\033[0m" "$y" "$x"
          ;;
        2 | 34) # 右ボタンクリック、ドラッグしている場合
          # (x, y) に 黄色で"@" を描画
          printf "\033[1;33m\033[%d;%dH@\033[0m" "$y" "$x"
          ;;
      esac

      # ターミナル下部にマウスの位置情報を表示する
      printf "\033[999;1H"    # 画面の下へ移動
      echo -n "button=$b, x=$x, y=$y "
    fi
  fi
done

```


### 4.1 `orig_stty=$(stty -g)`
 現在のターミナル設定を保存
 
 （スクリプト終了時に `stty "$orig_stty"` で元の状態に復元できるようにするため）

### 4.2 `cleanup()` 関数

- 端末を元の状態に戻す処理

```bash
cleanup() {
  # マウストラッキングを解除
  echo -ne "\033[?1003l"
  # stty 設定を復元
  stty "$orig_stty"
  echo -e "\nマウスモードを解除しました。終了します。"
  exit
}
```
`trap cleanup EXIT INT TERM` で、プログラム終了時(EXIT)や、Ctrl+C(INT)時に端末の状態を元に戻します


### 4.3 `clear()` 関数

- `\033[2J` で画面全体クリア、`\033[H` でカーソルを左上へ移動してから、メッセージを出力します
```bash
clear() {
  printf '\033[2J\033[H' 
  printf "左ボタンを押しながら移動すると '*'、右ボタンだと'@'を出力します。\r\n"
  printf "終了するには[Ctrl+C]、画面をクリアするには [c] キーを押してください。\r\n"
}
```


## 5. まとめ
- **Any‑event tracking (`033[?1003h`)** でマウス移動を含む全イベントを取得できるようにする
- 受信したマウスイベント `ESC [ M b x y` を解析し、ボタンの状態 `b`と座標 `(x,y)` に分解する
- 左ドラッグ時は赤「*」、右ドラッグ時は黄「@」をマウスの位置に描画する
- `c`キーで画面クリア、`Ctrl+C` で元のターミナル設定に戻して終了する

## 6. 参考

* [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html)

## 7. 変更履歴

### 2025/05/05 @ttdoda さんからの指摘の対応
* エコーバックや行編集を行うのはターミナル自身が行うという記述になっていたのを修正（ラインディシプリンが行う)
* bashはrawモードで動作しており、ラインディシプリンと同様の処理も行っていることを追記
* シェルスクリプトの終了時処理が２回行われていたのを修正
* マウスボタンクリック(押下)時にも描画するように修正