.NETでのコンソールのバッファーとウィンドウについて


# 1.【概要】
最近、.NET のコンソールアプリケーションを色々作っているのですが、 **カーソルの位置決めに使用する座標って何を指定すればいいの？** という非常に基本的なところで悩んでいました。
つまり、`(0, 0)` のカーソル位置はどこを指してるの？ ってことです。
余りにも基本的すぎる疑問であるせいか、逆にこういったことについて触れている記事はほとんど見当たりませんでした。
いや、そもそもカーソル位置の原点って `(0, 0)` なんでしょうか、それとも `(1, 1)` なんでしょうか。

そういった疑問からこの記事は始まりました。
非常に退屈な記事かもしれませんが、よろしかったらお付き合いください。

# 2.【対象環境】
この記事の内容は以下の環境において検証しました。

+ OS
  + Windows 10
  + Ubuntu 20.04 (linux on Windows)
+ .Net Runtime
  + 7.0
+ コンソール:
  + コマンドプロンプト (Windows 10付属版)
  + コマンドプロンプト (Windowsターミナル版)
  + ubuntu (Windowsターミナル版)

# 3.【コンソールというモノについて】
座標系の話をする前に、そもそもコンソールアプリケーションから見たコンソールってどういうものなんでしょうか。
Windows API や ANSI エスケープコード関連の記事とかを色々調べると、だいたい以下のような特徴を備えているようです。

![0000202303161948362550000.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/381778/65763f7f-3d55-93f7-a6b6-d61f0bfe4349.png)

+ コンソールバッファーというものがあり、それまでに表示した文字を保存している。
+ コンソールバッファーの一部を表示するためにコンソールウィンドウ (コンソールスクリーンとも呼ばれている) があり、コンソール (コマンドプロンプトや Windows ターミナル、その他) で直接表示されるものはコンソールウィンドウの内側だけ。
+ コンソールウィンドウはコンソールバッファーの中を自在にスクロールできる。大抵はコンソールにそのためのスクロールバーがついている。

# 4.【カーソル位置の原点はどこか？ (実験)】
ここから本題です。
まぁ、普通に考えたら **画面の一番左上が `(0, 0)` あるいは `(1, 1)` だよね** って考えますよね。
なので、それを検証するプログラムを組んでみました。~~ここまで書いておいて何ですが、相当暇人ですよね、私。~~

## 4.1 検証用プログラムのソースコード

```c#
using System;

namespace Experiment
{
    public static class Program
    {
        public static void Main(string[] args)
        {
            Console.Clear();
            var (homeCursorLeft, homeCursorTop) = Console.GetCursorPosition();
            for (int i = 0;  i < 100; i++)
                Console.WriteLine("<空白行>");
            var (newCursorPositionLeft, newCursorPositionTop) = Console.GetCursorPosition();
            var windowLeft = Console.WindowLeft;
            var windowTop = Console.WindowTop;
            var windowWidth = Console.WindowWidth;
            var windowHeight = Console.WindowHeight;
            var bufferWidth = Console.BufferWidth;
            var bufferHeight = Console.BufferHeight;
            Console.WriteLine($"Cursor home position=({homeCursorLeft}, {homeCursorTop})");
            Console.WriteLine($"({nameof(Console.CursorLeft)}, {nameof(Console.CursorTop)})=({newCursorPositionLeft}, {newCursorPositionTop})");
            Console.WriteLine($"({nameof(Console.WindowLeft)}, {nameof(Console.WindowTop)})=({windowLeft}, {windowTop})");
            Console.WriteLine($"({nameof(Console.WindowWidth)}, {nameof(Console.WindowHeight)})=({windowWidth}, {windowHeight})");
            Console.WriteLine($"({nameof(Console.BufferWidth)}, {nameof(Console.BufferHeight)})=({bufferWidth}, {bufferHeight})");
        }
    }
}
```

このプログラムでやっていることは以下の2点です。
+ 画面を全クリアした後 100 行改行して、改行前後のカーソル位置を表示する。
+ コンソールバッファーのサイズ、コンソールウィンドウ位置とサイズを表示する。

これをいろんなコンソールで実行してみます。

## 4.2 コマンドプロンプト (Windows 10 付属版) の場合

まずは Windows 10 の コマンドプロンプト で実行するとこんな結果が表示されました。

```
...
<空白行>
<空白行>
Cursor home position=(0, 0)
(CursorLeft, CursorTop)=(0, 100)
(WindowLeft, WindowTop)=(0, 71)
(WindowWidth, WindowHeight)=(120, 30)
(BufferWidth, BufferHeight)=(120, 9001)
```

カーソル位置の原点は `(0, 0)` ということで一つ疑問が解消されました。
では、カーソル位置の原点はどこを指しているのでしょうか。

`(WindowWidth, WindowHeight)=(120, 30)`と表示されているので、ウィンドウの大きさは 120文字x30文字 のはずですが、`CursorTop` が 100 という値になっています。
`WindowTop`の値が 71 ということは 71 行だけ下にスクロールしたことを意味しますので、**カーソル座標の原点はコンソールバッファーの一番左上**と考えると辻褄が合います。

うん、納得しました。
では他のコンソールではどうでしょうか。

## 4.3 コマンドプロンプト (Windows ターミナル版) の場合

次に、Windows ターミナル版コマンドプロンプトを起動して、その上で検証用プログラムを実行してみました。以下がその結果です。

```
...
<空白行>
<空白行>
Cursor home position=(0, 0)
(CursorLeft, CursorTop)=(0, 25)
(WindowLeft, WindowTop)=(0, 0)
(WindowWidth, WindowHeight)=(89, 26)
(BufferWidth, BufferHeight)=(89, 26)
```

…正直、Windows 10 版のコマンドプロンプトと違う結果が出て困惑しています。

画面全クリア直後のカーソル位置 (つまりカーソル座標の原点) が `(0, 0)` なのはいいです。むしろ違ったらマジで困ります。

何で 100 行も改行しているのに `CursorTop` が 25 とかなんですかね…
あと、`(BufferWidth, BufferHeight)`の値がしれっと`(WindowWidth, WindowHeight)`と一致してます。
つまりこれが意味するところは **「コンソールバッファー？ コンソールウィンドウで見えるところがすべてですよ？」** ということなのでしょう。
Windowsターミナル版コマンドプロンプトにもスクロールバーはついていて、26行どころじゃない行数をさかのぼって内容を見ることが出来てるんですけどねぇ…

とりあえず、考察は後回しにして、別のコンソールでも実験してみます。

## 4.4 ubuntu (Windowsターミナル版) の場合

Linux on Windows で ubuntu を導入して、Linux 環境下でも実験してみました。
以下がその実行結果です。
```
...
<空白行>
<空白行>
Cursor home position=(0, 0)
(CursorLeft, CursorTop)=(0, 25)
(WindowLeft, WindowTop)=(0, 0)
(WindowWidth, WindowHeight)=(89, 26)
(BufferWidth, BufferHeight)=(89, 26)
```
Windows ターミナル版コマンドプロンプトと同じ結果となりました。スクロールバーでさかのぼって27行以上前の行の内容を見ることが出来るのも同じです。

実を言うとこの結果は予想できていました。というのは、以前に `Console`クラスの UNIX 版の実装ソースコードを調査したことがあり、その結果`BufferWidth`の値として`WindowWidth`の値が、`BufferHeight`の値として`WindowHeight`の値がそのまま返されていることがわかったからです。

# 5.【問題点の考察】

## 5.1 コンソールバッファーの扱いがコンソールによって異なる

Windowsターミナル版のコマンドプロンプトと ubuntu ではコンソールバッファーとコンソールウィンドウの関係は以下のようになっているようです。
+ **「コンソールバッファーのサイズ ＝ コンソールウィンドウのサイズ」** となっている。
+ コンソールウィンドウの起点が常に `(0, 0)` となっている。
+ コンソールのスクロールバーを操作するとコンソールバッファーの行数よりも前の内容を見ることが出来る。

総合的に捉えると、 **「何らかの技術的な理由で API からはコンソールバッファーのサイズを取得できないので、イコール コンソールウィンドウのサイズとしてお茶を濁している」** といったところでしょうか。
.NETの`Console`クラスの動作上のみで語れば辻褄は合っていますが、何かもやもやします。

まぁ、コンソールバッファーの扱いについては対策も可能なのですが、もう 1 つどうにもならない問題が今回の実験で見つかりました。

## 5.2 `Console.Clear()` によってコンソールバッファーがすべて消えないことがある

実験をしてみて初めて分かったのですが、Windows ターミナル版コマンドプロンプトと、Windows ターミナル版 ubuntu では、`Console.Clear()`を発行してもコンソールバッファーが完全には消去されません。

Windows ターミナル版コマンドプロンプトの場合は、そのときのコンソールウィンドウの内容は消去されますが、その前の行の内容はそのまま残っていて、スクロールバーでさかのぼると内容が残っています。

ubuntu にいたっては、 そもそも **コンソールバッファーの削除は行われず、まだ文字が表示されていない下方の行に強制的にスクロールが行われるだけです。** 確かに一見消去されたようには見えますが、スクロールバーで前の行に戻ると内容が丸ごと残っているのがわかります。


[`Console.Clear()`の仕様](https://learn.microsoft.com/en-us/dotnet/api/system.console.clear?view=net-7.0#remarks)にはこうあります。(日本語仕様書の機械翻訳があまりあてにならないので敢えて英語版を引用しています)

> Using the Clear method is equivalent invoking the MS-DOS cls command in the command prompt window. When the Clear method is called, the cursor automatically scrolls to the top-left corner of the window and the contents of the screen buffer are set to blanks using the current foreground background colors.

コマンドプロンプトの`cls`コマンドと同等と書いてあるのですが、明らかに同じ仕事をしていません。
ちなみに、Windows 10 版コマンドプロンプトで `cls` コマンドを叩くと、当然のことながらコンソールバッファーごと消去され、スクロールバーで戻ろうとしても何もありません。要するに普通に期待する結果になります。Windows ターミナル版 コマンドプロンプトでも同様です。

.NETの`Console`クラスの Windows 版と UNIX 版のソースコードを調査したところ、`Console.Clear()`は以下の様に実装されているようです。
+ Windows の場合
`FillConsoleOutputCharacter`と`FillConsoleOutputAttribute`の2つのWin32 APIを呼び出して、コンソールバッファーを空白文字で埋めて、かつ背景色で染めているようです。
もしコンソールが正しいコンソールバッファのサイズを返していないのなら完全には消去されないのも納得がいきます。
+ UNIX の場合
UNIX の場合は、コンソールに ANSI エスケープコードを送信することによって画面消去やその他の機能を実現していますが、どのようなコードを送信するかはどのタイプのコンソールを使っているかによります。
Windows ターミナル版 ubuntu の場合、`xterm-256color`という端末をエミュレートしているようで、この端末の場合は`Console.Clear()`の際に `"\x1b[H\x1b[2J"` というコードを送信しているようです。
このコードの意味は「カーソルをホームポジションに戻して、かつコンソールウィンドウをすべて消去する」です。
**どうしてコンソールバッファーじゃなくてコンソールウィンドウの消去なの？**[^1] とか、実際の消去の方法とか、いろいろツッコミどころが多い実装ですね…

# 6.【どういう対策が可能か】
## 6.1 `Console.Clear()` でコンソールバッファーが完全に消去されないことがある件について
`Console.Clear()`の動作がコンソールによって異なるのは、OS や コンソールの仕様に依存しているようなので、アプリケーション側での対策はかなり難しいように思えます。
コンソールウィンドウが消去されはしますが、コンソールバッファーが残ってしまうことがあることを前提に開発をするしかないでしょう。

## 6.2 カーソル位置の座標系について
そもそも本命の話題だったカーソル位置の座標系についてです。

Windows 10 版コマンドプロンプトでは、コンソールバッファーの左上が原点となっています。

一方、Windows ターミナル版コマンドプロンプトや ubuntu ではコンソールウィンドウの左上が原点となってはいます。
しかし、 **`Console`クラスの各種プロパティの値はコンソールウィンドウがコンソールバッファの左上に固定されていることを示しています。** なので、この場合も「コンソールバッファーの左上がカーソル座標の原点」と考えていいでしょう。

ただ、コンソールバッファーの左上が原点という座標は通常扱いづらいと思えます。
といって、カーソル位置を設定するのに Windowsターミナル版コマンドプロンプトや ubuntu では以下のようなコードを書きたくなりますが、これでは Windows 10 版コマンドプロンプトの場合に正しい位置に設定できません。
```c#
int cursorLeft = 10; // コンソールウィンドウ上の左端からの桁数 (左端が 0)
int cursorTop = 5; // コンソールウィンドウ上の上端からの行数 (上端が 0)
Console.CursorLeft = cursorLeft; // Windows 10 版コマンドプロンプトでは正しい位置にならない
Console.CursorTop = CcursorTop; // Windows 10 版コマンドプロンプトでは正しい位置にならない
```

なので、これまでに挙げたどのコンソールでも正しい結果を生むためには、以下のようにコードを記述する必要があるでしょう。

```c#
int cursorLeft = 10; // コンソールウィンドウ上の左端からの桁数 (左端が 0)
int cursorTop = 5; // コンソールウィンドウ上の上端からの行数 (上端が 0)
Console.CursorLeft = Console.WindowLeft + cursorLeft;
Console.CursorTop = Console.WindowTop + cursorTop;
```
Windows ターミナル版コマンドプロンプトや ubuntu では `Console.WindowLeft` と `Console.WindowTop` の値は常に 0 なので[^2]、こうしておけば互換性は保たれます。

逆に、コンソールウィンドウの左上を原点としたカーソル位置を取得したい場合は、以下のようにコードを記述する必要があるでしょう。
```c#
int cursorLeft = Console.CursorLeft - Console.WindowLeft; // コンソールウィンドウの左端からの桁数 (左端が0) の取得
int cursorTop = Console.CursorTop - Console.WindowTop; // コンソールウィンドウの上端からの行数 (上端が0) の取得
```

# 7.【まとめ】
ここまでの内容を以下にまとめます。
+ .NET の`Console`クラスにおいて、カーソル位置の原点は `(0, 0)` であり、コンソールバッファーの左上を指す。
+ コンソールウィンドウの左上を原点にした座標でカーソル位置を設定/参照する場合は [「カーソル位置の座標系について」](#62-%E3%82%AB%E3%83%BC%E3%82%BD%E3%83%AB%E4%BD%8D%E7%BD%AE%E3%81%AE%E5%BA%A7%E6%A8%99%E7%B3%BB%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6) のようにするのがよい。
+ コンソールによっては `Console.Clear()`によってコンソールバッファーが完全には消去されないことがあるが、コンソールや OS 依存の問題なので素直に諦める。


[^1]: ANSI エスケープコードについては、「その端末の場合その目的にはこのコードを送信するように！」とOSが取り決めた対応付け (terminfoのことです) があって、.NET はそれに従っているだけなので、必ずしも .NET とか Windows ターミナルの問題ではないのでしょうが…

[^2]: .NET7.0時点で、UNIX 版の実装では`WindowLeft`プロパティと `WindowTop`プロパティの getter はともに 0 を返すようにハードコーディングされています。しかし、Windows 版では Win32 API を呼び出してその結果 0 が返るだけなので、0 が返るかどうかはおそらくコンソールに依存します。何れにしろ、.NETや Win32 API などの仕様の変更の可能性を考慮すると、**`WindowLeft`と `WindowTop`の値が将来も常に 0 であるという仮定はしない方がいいでしょう**。