Consoleでメニュー機能を作成してみる

## はじめに！
この記事は備忘録として作成しています。あくまで参考としてご覧ください。

### 何がわかるの？
この記事では、以下の機能を実装します。
* コンソールアプリのメニュー機能
* コンソール先頭へのスクロール
* 現在行の削除

## そもそも Console って？
プログラミングを学ぶ際、最初に書いたコードは何でしたか？
私は`Console.WriteLine("Hello World")`でした。

```C#:Program.cs
class Program
{
    [STAThread()]
    static void Main()
    {
        Console.WriteLine("Hello World!");
    }
}
```

**☆ 実行結果**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044159/7b237f26-342f-4e93-96b7-28a483f56299.png)

`Console` クラスは、コンソール操作を提供する **静的クラス** です。
代表的なメソッドには次のようなものがあります。
* `Console.WriteLine()`：文字列の出力
* `Console.ReadLine()`：ユーザー入力の取得

[📖 *Microsoft 公式ドキュメント: Console クラス*](https://learn.microsoft.com/en-us/dotnet/api/system.console?view=net-10.0 "📖 Microsoft公式ドキュメント: Console クラス")

## 本題
以下は、**コピー＆ペーストで動作する** メニュー機能の実装です。
```C#:Program.cs
    /// <summary>
    /// メニューを表示し、選択されたメニュー項目のインデックス <see cref="int"/> を返します。
    /// </summary>
    /// <param name="title">メニューのタイトルを示す文字列 <see cref="string"/>。</param>
    /// <param name="arrow">選択中の項目に表示する文字列 <see cref="string"/>。</param>
    /// <param name="items">メニューの項目を示す文字列の配列 <see cref="string[]"/>。</param>
    /// <returns>選択されたメニュー項目のインデックス <see cref="int"/>。</returns>
    /// <exception cref="ArgumentException">メニューの項目を示す <paramref name="items"/> が指定されていない場合に発生します。</exception>
    static int ShowMenu(string title, string arrow, string[] items)
    {
        // メニューの項目が指定されていない場合は例外をスロー
        if (items == null || items.Length <= 0) throw new ArgumentException("メニューの項目が指定されていません。");

        // 選択中のメニュー項目を示すインデックス
        int idx = 0;
        // 現在のカーソルの縦位置を取得
        int cursorTop = Math.Min(Console.CursorTop, Console.BufferHeight - Console.WindowHeight) + 1;
        // メニュー選択中を示すフラグ
        bool isSelected = false;
        // カーソルを非表示
        Console.CursorVisible = false;

        // ウィンドウをスクロール
        ScrollWindowTop();
        // タイトルを表示
        Console.WriteLine(title);

        do
        {
            // カーソルの位置を元に戻す
            Console.SetCursorPosition(0, cursorTop);

            // メニューを表示
            for (int i = 0; i < items.Length; i++)
            {
                // 現在カーソル位置の行をクリア
                ClearCurrentCursorPosition();
                // メニュー項目を表示
                Console.WriteLine(i == idx ? $"{arrow} {items[i]}" : $" {items[i]}");
            }

            // キー入力を待つ
            switch (Console.ReadKey(true).Key)
            {
                case ConsoleKey.UpArrow:    // 上矢印
                    if (idx > 0) idx--;
                    break;

                case ConsoleKey.DownArrow:  // 下矢印
                    if (idx < items.Length - 1) idx++;
                    break;

                case ConsoleKey.Escape:     // エスケープ
                    idx = - 1;
                    isSelected = true;
                    break;

                case ConsoleKey.Enter:      // エンター
                    isSelected = true;
                    break;
            }
        } while (!isSelected);

        // カーソルを表示
        Console.CursorVisible = true;

        return idx;
    }

    /// <summary>
    /// ウィンドウをスクロールして、メニューの先頭に移動します。
    /// </summary>
    static void ScrollWindowTop()
    {
        // ウィンドウの高さを取得
        int windowHeight = Console.WindowHeight - 1;

        // ウィンドウをスクロール（改行で擬似的なクリア）
        for (int i = 0; i < windowHeight; i++) Console.WriteLine();

#pragma warning disable CA1416 // 警告を抑制
        // ウィンドウの位置を先頭に移動
        Console.SetWindowPosition(0, 0);
#pragma warning restore CA1416

        // カーソルの位置をメニューの先頭に移動
        Console.SetCursorPosition(0, 0);
    }

    /// <summary>
    /// 現在のカーソル位置の行をクリアします。
    /// </summary>
    static void ClearCurrentCursorPosition()
    {
        // カーソルを先頭に移動
        Console.SetCursorPosition(0, Console.CursorTop);

        // 現在行のバッファの長さ分を空白で上書き
        Console.Write(" ".PadRight(Console.BufferWidth));

        // カーソルを元の位置に戻す
        Console.SetCursorPosition(0, Console.CursorTop);
    }
```
### メソッドの使用例

```C#:Program.cs
    [STAThread()]
    static void Main()
    {
        Console.WriteLine("Hello World!");

        string[] items = ["項目1", "項目2", "項目3", "項目4", "項目5"];

        int result = ShowMenu("▼ 項目を選択してください。", "→", items);

        Console.WriteLine();
        Console.WriteLine(result < 0 ? "▼ キャンセルされました。" : $"▼ {items[result]} が選択されました。");
    }
```
#### ☆ 実行結果
![console.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044159/16f7a3bc-b6dd-4049-91a8-a8000562adad.gif)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044159/f208ecc8-d7b1-4de4-b756-22607a98af37.png)


***


### 解説

<details><summary>ShowMenu メソッド</summary>

#### カーソル位置の制御する
```C#
// 現在のカーソルの縦位置を取得
int cursorTop = Math.Min(Console.CursorTop, Console.BufferHeight - Console.WindowHeight) + 1;
```
このコードは **現在のカーソルの位置を取得し、表示領域の範囲内に収める** ためのものです。

* `Console.CursorTop`：現在のカーソル位置
* `Console.BufferHeight`：コンソール全体の高さ
* `Console.WindowHeight`：表示領域の高さ
* `Math.Min()`：最小値を取得

通常の状況では **現在のカーソルの縦位置** をそのまま取得しますが
カーソル位置が **全体の行数 - 表示される行数** より大きい場合、その値で制限します。
（+1 はタイトルを表示する行数）

:::note warn
**※これらは以下が保証されている場合は必要ありません。**
* カーソルがウィンドウの範囲内に収まることが保証されている
* バッファの幅とウィンドウの幅が同じ
:::


***


#### カーソルのチラつかないようにする
```C#
// カーソルを非表示
Console.CursorVisible = false;
```
見た目上の問題のみです。


***


#### メニューが何度も表示されないようにする
```C#
// カーソルの位置を元に戻す
Console.SetCursorPosition(0, cursorTop);
```
カーソルの位置を戻すことで、新たに文字列を出力したときに
**カーソルの位置に既にある文字列が上書きされる**ようになります。

:::note
今回は`ClearCurrentCursorPosition()`を用いてしっかりとクリアしていますが
処理速度を求める場合、**不要な文字数のみ削除する**などの改案があると思います。
:::


***


#### メニュー項目の表示
```C#
// メニューを表示
for (int i = 0; i < items.Length; i++)
{
    // 現在カーソル位置の行をクリア
    ClearCurrentCursorPosition();
    // メニュー項目を表示
    Console.WriteLine(i == idx ? $"{arrow} {items[i]}" : $" {items[i]}");
}
```
三項演算子で現在選択中のメニュー（i == idx）に矢印をつけるようにしています。


***


#### キー入力の処理
```C#
// キー入力を待つ
switch (Console.ReadKey(true).Key)
{
    case ConsoleKey.UpArrow:    // 上矢印
    if (idx > 0) idx--;
    break;

    case ConsoleKey.DownArrow:  // 下矢印
    if (idx < items.Length - 1) idx++;
    break;

    case ConsoleKey.Escape:     // エスケープ
    idx = - 1;
    isSelected = true;
    break;

    case ConsoleKey.Enter:      // エンター
    isSelected = true;
    break;
}
```
`Console.ReadKey(true)`にてユーザーからの入力を待機し、
入力されたキーに応じた処理を switch 文で記述しています。
* `UpArrow` / `DownArrow` でメニューを移動
* `Escape` でキャンセル（-1 を返す）
* `Enter` で選択確定

</details>

<details><summary>ScrollWindowTop メソッド</summary>

```C#
// ウィンドウの高さを取得
int windowHeight = Console.WindowHeight - 1;

// ウィンドウをスクロール（改行で擬似的なクリア）
for (int i = 0; i < windowHeight; i++) Console.WriteLine();
```

メソッド名ではスクロールとしていますが、実際にはスクロールではありません。

コンソールで**表示されている領域をすべて改行し擬似的にクリア**を行い、

```C#
#pragma warning disable CA1416 // 警告を抑制
// ウィンドウの位置を先頭に移動
Console.SetWindowPosition(0, 0);
#pragma warning restore CA1416

// カーソルの位置をメニューの先頭に移動
Console.SetCursorPosition(0, 0);
```
その状態でカーソルの位置を先頭にセットすることで
**直前までの出力内容を残しつつ画面を一新**しています。

</details>


<details><summary>ClearCurrentCursorPosition メソッド</summary>

```C#
// カーソルを先頭に移動
Console.SetCursorPosition(0, Console.CursorTop);

// 現在行のバッファの長さ分を空白で上書き
Console.Write(" ".PadRight(Console.BufferWidth));

// カーソルを元の位置に戻す
Console.SetCursorPosition(0, Console.CursorTop);
```

こちらは解説するまでもありませんね。見ての通りです。

:::note
縦位置（行番号）を引数で受け取れば、指定行の削除もできますね！
元々いたカーソルの位置へ戻してあげる処理などが必要になるかもしれませんが。
:::

</details>

## さいごに！
この記事では、**コンソールアプリでのメニュー機能の実装** を紹介しました。

このコードを基に、**横方向のメニュー**や**グリッド形式のメニュー** など、
拡張性を考慮するなら **DI（依存性注入）** や **ポリモーフィズム** も選択肢に入りますね。

~~せっかくのオブジェクト指向なら、静的メソッドはあまり使いたくないなというね...。~~

**最後まで読んでいただき、ありがとうございました！🎉**