2024-01-21

F# でコマンドレットを書いてる pt.30

krymtkts/pocof の開発をした。

前回触れた PSHostRawUserInterface.LengthInBufferCells を使った full-width character の表示に対応した。 なので、pocof version 0.8 をリリースした。

PSHostRawUserInterface.LengthInBufferCells で query window の表示域に収まる長さを求めるのに再帰させた。 一発で長さを求められないあたり効率は良くない。 時間計算量が線形時間 O(n) なので、今後せめて二分探索で対数時間 O(logn) にできないか考える。


pocof の積み Issues は今 4 つある。

今は #85 Linux support を進めてる。

pocof は .NET Standard 2.0 をターゲットにしてるので、 Windows PowerShell でも PowerShell (Core 6 以降) でも動くはず。現在の dotnet はほんとすごいな。 でも PowerShell の ConsoleHost の実装で Windows 以外では NotImplementedException を投げる箇所がある。 以下に、 Ubuntu on Docker で確認したエラーを Issue から転記する。

PS /src> Get-ChildItem | Select-Pocof
Select-Pocof: The method or operation is not implemented.

PS /src> $ErrorView = 'DetailedView'
PS /src> $Error

Exception :
Type : System.NotImplementedException
TargetSite :
Name : GetBufferContents
DeclaringType : Microsoft.PowerShell.ConsoleHostRawUserInterface, Microsoft.PowerShell.ConsoleHost, Version=7.3.10.500, Culture=neutral, PublicKeyToken=31bf3856ad364e35
MemberType : Method
Module : Microsoft.PowerShell.ConsoleHost.dll
Message : The method or operation is not implemented.
Source : Microsoft.PowerShell.ConsoleHost
HResult : -2147467263
StackTrace :
at Microsoft.PowerShell.ConsoleHostRawUserInterface.GetBufferContents(Rectangle rectangle)
at System.Management.Automation.Internal.Host.InternalHostRawUserInterface.GetBufferContents(Rectangle r)
at pocof.PocofScreen.RawUI..ctor(PSHostRawUserInterface rui) in /src/src/pocof/UI.fs:line 55
at pocof.PocofScreen.init(PSHostRawUserInterface rui, String prompt, FSharpFunc`2 invoke) in /src/src/pocof/UI.fs:line 173
at pocof.SelectPocofCommand.interact(InternalConfig conf, InternalState state, Position pos, PSHostRawUserInterface rui, FSharpFunc`2 invoke) in /src/src/pocof/Library.fs:line 44
at pocof.SelectPocofCommand.EndProcessing() in /src/src/pocof/Library.fs:line 191
CategoryInfo : NotImplemented: (:) [Select-Pocof], NotImplementedException
FullyQualifiedErrorId : NotImplementedException,pocof.SelectPocofCommand
InvocationInfo :
MyCommand : Select-Pocof
ScriptLineNumber : 1
OffsetInLine : 17
HistoryId : 2
Line : Get-ChildItem | pocof
PositionMessage : At line:1 char:17
+ Get-ChildItem | pocof
+ ~~~~~
InvocationName : pocof
CommandOrigin : Internal
ScriptStackTrace : at <ScriptBlock>, <No file>: line 1
PipelineIterationInfo :

NotImplementedException の発生源はここかなと思ってる。 PowerShell/src/Microsoft.PowerShell.ConsoleHost/host/msh/ConsoleHostRawUserInterface.cs

        /// <summary>
/// This API returns a rectangular region of the screen buffer. In
/// this example this functionality is not needed so the method throws
/// a NotImplementException exception.
/// </summary>
/// <param name="rectangle">Defines the size of the rectangle.</param>
/// <returns>Throws a NotImplementedException exception.</returns>
public override BufferCell[,] GetBufferContents(Rectangle rectangle)
{
throw new NotImplementedException("The method or operation is not implemented.");
}

ConsoleHostRawUserInterface.cs の先頭に #if !UNIX てプリプロセッサディレクティブがあって、前半 Windows 後半その他になってる。これはその他の方。

そのため、 pocof では Linux の場合(厳密には Mac も含むが確認できない)に work around が必要だった。 今は NotImplementedException を捕捉した場合は GetBufferContents 未対応の platform とみなし、エラーを回避する。 この場合、コンソールに表示されているバッファのバックアップと復元はしない。

    type RawUI(rui) =
let rui: PSHostRawUserInterface = rui

// TODO: replace backup/restore buffer contents with scrolling contents for Linux support.
let buf: BufferCell [,] option =
try
rui.GetBufferContents(Rectangle(0, 0, rui.WindowSize.Width, rui.CursorPosition.Y))
|> Some
with // NOTE: when running on Linux, this exception is thrown.
| :? NotImplementedException -> None

元々 Windows で動いてるバッファのバックアップと復元も、可視範囲しか対応しないのと復元結果が微妙に壊れるので、イマイチではあるけど。

この課題を見つけて以降、 dotnet で cross-platform なコンソール操作どうなってんのやと調査を進めていた。 PowerShell で Windows 以外が未実装な機能もあれば、 dotnet 自体で Windows 以外の platform をサポートしないケースもある。 Support for Console.MoveBufferArea() on Linux & Mac · Issue #23073 · dotnet/runtime

コンソール周りの cross-platform は結構難しくて、 Windows でできることをそのまま他所ではできないのがわかった。


じゃあ pocof では描画域を確保したりバッファを復元したりの操作をどう変えていくべきか、悩ましいところだった。 どう解消すべきかなと調査していたところ、先達の重要なコメントを見つけた。

PS-GuiCompletion does not work in PowerShell Core 7.0 on Linux · Issue #13 · cspotcode/PS-GuiCompletion

Similar functionality, such as the Menucomplete feature of PSReadLine, work around this limitation by writing several blank lines to the terminal, scolling everything upward and creating an empty region at the bottom of the screen. They draw their "gui" into this empty region, knowing they can erase it entirely when they're done.

PSReadLine の補完機能で表示されるアレをパクれば良いと。なるほどーって感じ。 でも実装が大変なんでしょ?と思いコードを見に行ったところ、めちゃくちゃシンプルな仕組みだった。

PSReadLine/PSReadLine/DisplayBlockBase.cs at e57f7d691d8df8c1121fddf47084f96aea74a688 · PowerShell/PSReadLine

            protected void MoveCursorDown(int cnt)
{
IConsole console = Singleton._console;
while (cnt-- > 0)
{
console.Write("\n");
}
}

改行を印字したらカーソルが 1 行下に進むので、それを必要な高さ分行うだけで描画域を確保できると。 この方法であれば、コンソールのバッファを破壊することもない。

一点マウススクロールを制限できないのがどうかなーと、はじめは思った。 が、それは今までの pocof が「全画面モーダル」的なアプリだったからそう感じたわけで、これからは「全画面ウィンドウ」的なアプリと捉えればいいだけなのではと思えてきた。 従来であれば可視範囲だけバッファをバックアップするのでスクロールを制限できる代わりに、見えない範囲のバッファを吹っ飛ばす制限があったし。 これが全画面ウィンドウであれば、全画面をやめてバッファと共存するような下半分だけの UI もアリやなというアイデアにつなげることもできる。これは良さそうや。 何ならタイリングレイアウト派なので、自分の用途だと半分 UI の方が向いてそう。

ということで、 PSReadLine スタイルの描画をプロトタイプしてみた。 まず PowerShell で雑に書いてみて、次に F# で雑に書いてみた。これらは Gist に置いた。

以下は F# 版。

open System

[<TailCall>]
let rec readAndDisplay h w arr =
let k = Console.ReadKey(true)
let arr = k :: arr

if k.Key = ConsoleKey.Enter then
()
else
let take =
match List.length arr < h with
| true -> List.length arr
| _ -> h

arr
|> List.take take
|> List.map (fun k -> $"[{k.Key} - {k.Modifiers}]")
|> List.iteri (fun i s ->
Console.SetCursorPosition(0, i)
let s = $"{i} {s}"

Console.Write(s + String.replicate (w - String.length s) " "))

readAndDisplay h w arr

// test script for interactive console window.
// currently cannot prevent mouse scrolling.
let testConsoleWindowWithoutBufferCleaning () =
// backup cursor x position.
let x = Console.CursorLeft

// add lines to the end of the screen for scrolling using the PSReadLine method.
let h = Console.WindowHeight
let w = Console.WindowWidth

String.replicate (h - 1) "\n" |> Console.Write

// write contents.
let yy = Console.CursorTop

Console.SetCursorPosition(0, 0)

[ 0..yy ]
|> List.map (fun i ->
let s = $"Line: %d{i}"
s + String.replicate (w - s.Length) " ")
|> String.concat "\n"
|> Console.Write
// read and display.
readAndDisplay h w []

// clear contests.
[ 0 .. (h - 1) ]
|> List.iter (fun i ->
Console.SetCursorPosition(0, i)
Console.Write(String.replicate w " "))

// restore cursor position.
Console.SetCursorPosition(x, 0)

testConsoleWindowWithoutBufferCleaning ()

こんな感じに動く。

F# での PoC キャプチャ

Windows の他に Ubuntu on WSL でも動かしてみた(disk カツカツで Docker やめた)が、良さそう。 前方スクロールしてたとしても、入力をはじめたらカーソル位置の操作が行われて全画面位置に戻る。いい。 これを pocof に組み込んでみて、まずは使ってみる。 違和感なくいけそうなら 0.9 で出すかな。