これは PowerShell Advent Calendar 2016 の 22 日目のエントリーです。
PowerShell と WPF (Windows Presentation Foundation) という単語が並んだ場合、多くの方は PowerShell から WPF ウィンドウ等の GUI を扱う方法について想像されると思いますが、本エントリーはその逆です。
即ち、WPF で PowerShell コンソールを作成する手段について扱います。
最終的に、以下のような形で自身のアプリケーション内にビルトインの PowerShell コンソールを実装し、コマンドレットやスクリプトの実行とそれに派生し独自データのフィルタリング等を行えるようにします (画像はクリックで拡大されます)。
アプリケーション内にビルトイン PowerShell コンソールを搭載している身近な例としては、Windows 付属の PowerShell ISE が挙げられるでしょう。
また、Visual Studio も Package Manager Console Host がそれに該当します。
いずれも WPF 製のアプリケーションです。
やろうと思えば 6 ~ 7 年前でもできるような内容のため、特に新規性はありません。
一方で、手を出す人があまりいなさそうな分野でもある (≒需要がない) ため、Advent Calendar という機会を借りて紹介します。
先行研究
MSDN に同じ内容と思われるシリーズが存在するのですが、Part 1 しか公開されていないのが非常に惜しい… もし Part 2 以降が公開されていれば、このエントリーは不要だったかもしれません。
また、WPF で PowerShell コンソールを実装したプロジェクトとして PoshConsole があります。中身はよく見ていない。
C# と PowerShell
C# (.NET Framework アプリケーション) から PowerShell スクリプトやコマンドレットを実行するには、System.Management.Automation.dll を使用します。
NuGet から Microsoft.PowerShell.5.ReferenceAssemblies パッケージをインストールするのが楽です。
私の理解では、対応させたい PowerShell のバージョンに応じて以下のパッケージを使い分けることになります。
いずれのバージョンにおいても、PowerShell.Create()
で PowerShell インスタンスを作成すれば、最低限の実行環境となります。
AddCommand
や AddScript
メソッドでコマンドレットやスクリプトを追加して、Invoke
メソッドを叩くだけです。
using (var powershell = PowerShell.Create())
{
powershell.AddCommand("Get-ChildItem");
powershell.AddCommand("Out-String");
foreach (var result in powershell.Invoke())
{
Console.WriteLine(result);
}
}
ですので、入力ボックスで受け入れた PowerShell コマンドレットまたはスクリプトを実行し、その結果を別のラベルに出力する、程度であれば簡単で作れます。
発生したエラーもそれらしいフォーマットの文字列を用意して出力すれば、PowerShell らしい結果を得られます。
通常の結果に関しても、Out-String
にパイプすると整形された結果を得られます (実際、PowerShell ISE は最後のコマンドレットが Out-String
でなければ Out-String
にパイプして出力するようにしている模様)。
private const string _errorMessageFormat =
"{0}\r\n" +
"{1}\r\n" +
" + CategoryInfo : {2}\r\n" +
" + FullyQualifiedErrorId : {3}\r\n";
//
var script = this.InputBox.Text;
var output = new StringBuilder();
using (var powershell = PowerShell.Create())
{
powershell.AddScript(script);
foreach (var result in powershell.Invoke())
{
output.Append(result);
}
if (powershell.Streams.Error != null && powershell.Streams.Error.Count > 0)
{
foreach (var error in powershell.Streams.Error)
{
var message = string.Format(
_errorMessageFormat,
error,
error.InvocationInfo.PositionMessage,
error.CategoryInfo,
error.FullyQualifiedErrorId);
output.Append(message);
}
}
}
//
// ToDo: エラー時にはテキストの色を赤く
this.Output.Text = output.ToString();
また PowerShell の特徴として、コマンドレット間のパイプが文字列ではなくオブジェクトで行われる点が挙げられます。
これを活かし、アプリケーション内の独自型のデータを PowerShell で処理することもできます。
独自データを返すコマンドレットを作成し、その型を含むアセンブリを Import-Module
コマンドレットでインポートします。
いい例が思いつかなかったので、WPF においてもっとも重要な型のひとつ[独自研究]である ContentControl の派生型を列挙する意味不明なコマンドレットを作りました…
[Cmdlet(VerbsCommon.Get, "ContentControl")]
public class GetContentControlCmdlet : PSCmdlet
{
private static ContentControlFamily[] _families;
protected override void ProcessRecord()
{
if (_families == null)
{
_families = AppDomain.CurrentDomain
.GetAssemblies()
.SelectMany(x => x.GetTypes())
.Where(x => x.IsSubclassOf(typeof(ContentControl)))
.Select(x => new ContentControlFamily()
{
FullName = x.FullName,
AssemblyQualifiedName = x.AssemblyQualifiedName,
})
.ToArray();
}
foreach (var data in _families)
{
this.WriteObject(data);
}
}
}
public class ContentControlFamily
{
public string FullName { get; set; }
public string AssemblyQualifiedName { get; set; }
}
using (var powershell = PowerShell.Create())
{
powershell.AddCommand("Import-Module").AddParameter("Name", typeof(GetContentControlCmdlet).Assembly.Location);
powershell.Invoke();
powershell.Commands.Clear();
powershell.AddScript("Get-ContentControl | where FullName -match 'Primitives' | Out-String");
foreach (var result in powershell.Invoke())
{
Console.WriteLine(result);
}
}
冒頭で示した GIF 画像は艦これのツールですが、保持しているすべての艦娘 (キャラクター) インスタンスを取得するコマンドレット Get-Ships
作って呼び出すことにより、艦娘のフィルターを書けるようにしています。
ここで書いたコードはごく簡単な例であり、System.Management.Automation.dll のより込み入った扱い方は他のブログ等を参照して頂ければと思います。
WPF でコンソール
続いて、コンソールの GUI の作り方です (「コンソールの GUI」ってお前は何を言っているんだ感)。
数ヶ月前に Metro.cs #2 で登壇した際、以下の資料の後半で WPF におけるコントロールの選択基準についてお話しました。
要するに、WPF においては「そのコントロールが提供する機能」に着目すべきであり、テンプレートで自由に作り変えられる外観はコントロールの選択に影響しない、ということです。
余談ですが、この資料、アップロードだけして非公開になっており、それに気付かず数ヶ月放置していたという失態を犯していました。ジャンピング土下座
つまり、WPF における GUI の実装においては、実現したい対象の挙動を分解することで適切なコントロールを選択すると良いです。
実はここが本エントリーで最も主張したかった内容となります。
コンソールを作るにあたり、その構成要素を観察すると「出力」「プロンプト」「入力エリア」の 3 要素があることが判ります。
また、ユーザーから文字入力を受け付けるのはキャレットが「入力エリア」にあるときのみで、「出力」「プロンプト」は文字入力を受け付けず、入力しようとするとキャレットを入力エリアに飛ばす挙動をとります。
つまり、キャレットが「入力エリア」にあるかどうかで文字入力の挙動を変えてやればいいわけで、TextBox.IsReadOnly のようにコントロールの機能で読み取り専用属性を付与させなくとも、緻密なキャレット制御により再現可能です。
以上を踏まえ、コンソールの GUI 要件を整理すると以下のようになり、これら実現できればコンソールとしての動作が可能になると言えます。
- 文字列の入力
- 文字列の出力
- 文字列の装飾
- キー入力制御
- キャレット制御
実は、これらを実現できる便利な標準コントロールがあります。
そう、RichTextBox です。
今回は、完全に自前実装するよりリーズナブルな方法として RichTextBox から派生したコントロールを作り、コンソール相当の GUI を実装しました。
サンプル コードは下記リポジトリで公開しています (ただのサンプルなのに名前が仰々しすぎる)。
https://github.com/Grabacr07/AvalonShell
単一エントリー内で解説するには巨大な量になってしまったので、詳細はリポジトリ内のコードをご覧いただくとして、以降は GUI 実現にあたってのポイントをいくつかかいつまんで紹介します。
コードを読む際は、PowerShell の入力から結果出力までの 1 サイクルを IPowerShellInvocation
というインターフェイスで抽象化し、コントロールからは PowerShell API を直接触らない設計にしている点にご留意ください。
これまた余談ですが、PowerShell ISE と Visual Studio はいずれも RichTextBox ではなく ContentControl 派生の Microsoft.VisualStudio.Text.Editor.Implementation.WpfTextView という型を使用しており、こちらはテキストの入出力などすべて自前で実装しているようです。スゴイネー
FlowDocument
RichTextBox は子要素として FlowDocument を持っており、入力や実行結果を Blocks に追加していく形を取れます。
出力部分を 1 つの Paragraph、プロンプトと入力エリアを 1 つの Paragraph として (下方向に) 積み上げていくイメージです。
下記画像の枠それぞれが Paragraph になります。
また、FlowDocument はドキュメント内の位置を TextPointer という型で表現していることも押さえておくべきでしょう。
ドキュメントを構成する Paragraph や Inline などの各要素は先端位置と終端位置をそれぞれ TextPointer 型の ContentStart
/ContentEnd
プロパティで公開しているほか、キャレット位置も TextPointer 型の CursorPosition
プロパティで公開しています。
そのため、「現在入力中の Paragraph」を記憶しておけば、キャレットがその Paragraph 上にいるかどうか判断するのは容易です。
これを利用し、下記のコードでキャレットが入力エリア内にいるかどうかを確認できます。
public Paragraph Editor { get; }
public string Prompt { get; } // e.g. "C:\hoge > "
public bool CaretIsInEditArea(TextPointer caretPostion)
=> this.Editor.ContentStart.IsInSameDocument(caretPostion)
&& this.Editor.ContentStart.GetOffsetToPosition(caretPostion) - 1 >= this.Prompt.Length;
5 行目が「キャレット位置がプロンプト&入力エリアと同じ Paragraph にいるかどうか」の判定で、6 行目の判定が「Paragraph 行頭とキャレットの相対位置から、プロンプトでなく入力エリアにいること」の判定です。
コントロールの PreviewKeyDown イベント等で上記の判定を使用すれば、「キャレット位置が入力エリア内のときだけの処理」を書けます。
この辺のコードです。
切り取りや貼り付けの防止
入力エリア外では文字列の選択は可能ですが、切り取りや貼り付けは防ぐ必要があります。
CommandManager の PreviewExecuted 添付イベントを使用し、特定のコマンドだけ抑止することができます。
public PowerShellConsole()
{
CommandManager.AddPreviewExecutedHandler(this, this.HandleCommandExecuted);
}
private void HandleCommandExecuted(object sender, ExecutedRoutedEventArgs args)
{
if (!this.CaretIsInEditArea())
{
if (args.Command == ApplicationCommands.Cut ||
args.Command == ApplicationCommands.Delete)
{
args.Handled = true;
}
}
}
上記コードにおいて ApplicationCommands.Paste
も監視すれば貼り付けを抑止できます。
しかしながら PowerShell の挙動を観察すると、入力エリア外で貼り付けられると入力エリアに飛ばし、貼り付ける挙動をとっているようです。
そこで、DataObject の Pasting 添付プロパティを使用します。
以下のコードで、貼り付け操作に割り込み、貼り付けようとしているオブジェクトが文字列の場合はキャレット位置を入力エリアに飛ばすことができます。
public PowerShellConsole()
{
this.Loaded += (sender, args) => DataObject.AddPastingHandler(this, this.HandlePaste);
this.Unloaded += (sender, args) => DataObject.RemovePastingHandler(this, this.HandlePaste);
}
private void HandlePaste(object sender, DataObjectPastingEventArgs e)
{
var isText = e.SourceDataObject.GetDataPresent(DataFormats.UnicodeText, true);
if (!isText) return;
var text = e.SourceDataObject.GetData(DataFormats.UnicodeText) as string;
if (text == null) return;
this.CaretPosition = this.Document.ContentEnd;
}
おわりに
PowerShell コンソールを作ろうと思ったのは、私の自作アプリ (KanColleViewer) の一覧系の画面においてビルトインで提供しているフィルターだけでは不足になることがあり、ユーザーがカスタム フィルターを書けるようにしたいと思ったのが発端です。
本来であれば独自クエリや C# Scripting などで対応すべきところだとは思いますが、コンソールの実装自体は WPF の GUI 開発のネタとして面白いかな、と考え作ってみた次第です。
公開しているサンプルは、現時点では (時間の都合で) 完全にコンソールを再現しているとは言えず、例えば Ctrl+A で全選択して Delete キーを押すとぜんぶ消せてしまったりします。
こういった細かな穴を埋めていけば、最終的に PowerShell ISE などのようなビルトイン PowerShell コンソールを実現できるでしょう。