Quantcast
Channel: C# – grabacr.nét

WPF + MEF、プラグイン側で画面を用意する

$
0
0

WPF は .NET Framework アプリですし、Managed Extensibility Framework (MEF) を活用したプラグインに対応したアプリを作れるのも強みのひとつです。

@takeshik 氏が KanColleViewer に MEF によるプラグイン機構を実装してくださいました。 で、WPF アプリでプラグインとなると、プラグイン側の設定画面等々の画面をプラグイン側で用意し、かつその画面にテーマを反映させたいものですね。

MEF のサンプルそのものは MSDN 等でよく見かけるものの、WPF アプリで、かつ拡張側 (プラグイン側) で画面を用意した軽量なサンプルはなかなか見つけられませんでした (節穴 eye という可能性)。

ということで、自分で作りました。

ダウンロードはこちらから。
http://code.msdn.microsoft.com/Creating-a-WPF-Visual-3eb4e5dc

/* 英語化は @guitarrapc_tech 先生がやってくださいました。超かっこいい。ありがとうございました。 */

概要

簡単な WPF アプリと 2 つのプラグインを用意し、各プラグインには設定画面を実装させます。 WPF アプリから各プラグインの設定画面を呼び出せるようにし、その設定によってプラグインの動作が変わるようにしました。

プロジェクト構成

VisualPlugin.UI

WPF アプリ本体。プラグインをロードし、各プラグインの情報の表示と設定画面の呼び出しをサポートします。

VisualPlugin.Interfaces

プラグインのインターフェイス。 サンプルとして、以下のメンバーを持つインターフェイスを公開しています。

public interface IVisualPlugin : INotifyPropertyChanged
{
    // プラグインの名称。
    string Name { get; }

    // プラグイン固有の処理。
    void Proc();

    // プラグインの設定画面を返すメソッド。
    object GetSettingsView();
}

VisualPlugin.Visuals

UI テーマ (共通で使用する色やフォント等) を定義するためのプロジェクトです。 WPF アプリ本体と各プラグインはこれを参照し、統一された外観を作れるようにします。

MetroRadiance や MahApps.Metro 等の UI ライブラリを使ったアプリ開発の場合は、このプロジェクトは必要ないです。

VisualPlugin.Sample1 / Sample2

プラグインのサンプル。 Sample 1 は、Proc() でメッセージボックスを表示します。 Sample 2 は、Proc() で別ウィンドウを表示し、設定されたパスの画像を表示します。

解説

MEF そのものについては他の資料をご覧いただくとして、プラグイン側で画面を返す部分について。

プラグイン側

設定画面そのものは、Sample1 / Sample2 プロジェクト内でユーザー コントロールとして定義しています。 ユーザーコントロールでは、アプリ本体 (VisualPlugin.UI) と同様の外観とするため、VisualPlugin.Visuals にある各リソースをマージしています。


    
        
            
            
            
        
    

そして、プラグイン本体の実装は以下。

public object GetSettingsView()
{
    return new Settings
    {
        DataContext = this
    };
}

設定画面のユーザー コントロールを生成し、返します。 呼び出し側 (つまり、WPF アプリ本体) の任意のタイミング作らせた方がスレッド等々の都合がよいので、このような形にしました。

そして、生成するユーザー コントロールの DataContext に自身のインスタンス (もしくは、ViewModel を噛まして) 与えてやりましょう。 WPF アプリ本体にロードされているインスタンスの動作をリアルタイムに変更することが可能です。

WPF アプリ本体側

まず、ViewModel でプロパティとして設定画面を公開しました。

public class PluginViewModel : ViewModel
{
    private object _settingsView;

    public IVisualPlugin Plugin { get; private set; }

    public object SettingsView
    {
        get { return this._settingsView ?? (this._settingsView = this.Plugin.GetSettingsView()); }
    }

    public PluginViewModel(IVisualPlugin plugin)
    {
        this.Plugin = plugin;
    }
}

SettingsView がそのプロパティ。 余談ですが、null 合体演算子を使ったこの書き方、割と好きです。

画面では ContentPresenter を使用して、SettingsView の内容を表示します。

<ContentPresenter Content="{Binding SettingsView}" />

ContentPresenter は、Content プロパティや ContentTemplate プロパティの内容によって、表示する内容を決定します。 (そのロジックは MSDN の解説を参照)

プラグイン側の GetSettingsView() がユーザー コントロール (UIElement) を返せば、それがそのまま表示されます。

実行結果

まず、WPF 本体がロードしたプラグインを一覧で出します。 (クリックで拡大)

MainWindow

そして、[Settings] ボタンで設定画面を表示。 たとえば、次のような UserControl は…

ヴィジュアルステューディオ

こんな感じで表示されます。Style もばっちり。

Settings view

また、プラグインの設定画面で変更した内容は、そのプラグインをロードしている WPF アプリ側に即時に反映されます。

サンプルのダウンロード

冒頭にも示しましたが、こちらからどうぞ。
http://code.msdn.microsoft.com/Creating-a-WPF-Visual-3eb4e5dc

KanColleViewer にも早いところ乗せてみたい。


Room metro Tokyo #4 資料公開

$
0
0

第 4 回めとべや東京勉強会でセッションしました。

かつてない程のgdgdなセッションを披露してしまい大変反省。 やりたかったのは、KanColleViewer の開発ネタの放出と、de:code のデスクトップ アプリ関連情報のキャッチアップでした。

de:code、もうちょっとデスクトップの情報出てくるかなと思ってたんですが、実際はかなり少なくて、何喋るか直前まで迷った挙句の結果でした。

セッション中にデモした @CST_negi 氏作のかっこいいランチャーアプリですが、今回 WindowStyle.Note の例としてデモしたいがために GitHub で公開して頂きました。ありがとうございました。
https://github.com/NegishiTakumi/ARiALauncher

後半の Windows 8.1 の High DPI と WPF での対応方法については、当ブログに該当するエントリーがありますので、そちらも併せてご覧ください。
http://grabacr.net/archives/1132

WindowChrome class 等に関しては、別途フォローアップのエントリーを用意したく。

VSTU 勉強会で LT しました

$
0
0

日本 Android の会 Unity 部、Visual Studio Tools for Unity 勉強会にて LT させて頂きました。

タイトルを考えたのは @neuecc であり、私ではありません、断じて。 別に Boo に親を殺されたわけでもないし、特段恨みもありません!

が、流石に Boo.Lang.dll は必要ないので、VSTU による .csproj の自動生成処理をフックして削除する方法についてご紹介しました。 要は LINQ to XML で好きに弄ればいいということです!

ライブ コーディングした SATSUGAI 用コードは以下になります。

この方法を使えば、Q&A でも話題に上がった #define の追加や unsafe 関連の設定もできる、はず、です (実際にやったことはありませんが、いずれも .csproj に書かれる設定なので)。

うまく活用し、快適な C# + Visual Studio ライフを送って下さい!

Unity のための C# 勉強会 セッション資料

$
0
0

Unity のための C# 勉強会にて UniRx + Reactive Property のお話をさせて頂いたので、資料を公開します。

実は一晩で作った資料なのでいろいろ雑っぽい いろいろ準備不足すぎて大変アレでしたが… 会場には Rx に触れられたことのない方も多くいらっしゃったようで、Rx (UniRx) がどんなものか、触りだけでも知って頂ければ幸いです。

資料内にも書きましたが、ガッツリ勉強するなら以下のスライドは必読ですよ!

Reactive Programming by UniRx for Asynchronous & Event Processing (@neuecc)
未来のプログラミング技術をUnityで ~UniRx~ (@toRisouP)

特に @toRisouP 先生の資料は、Rx のとっつきにくさを資料の解りやすさで解消するアプローチを取られていて、本当に素晴らしいです。 一方私は、セッション中のライブコーディングで実際にお見せして親しんでもらおうというアプローチ、をとろうとするのですが (資料読むだけセッションって性に合わなくて…)。 もうちょっと手際よくやらないとダメですね。反省。

Thumb コントロールで Photoshop のナビゲーターを再現する

$
0
0

@kurosawa0626 さんが Photoshop のナビゲーター的なものを作りたい、という話をしていて、過去に似たようなものを作ったことがあったので共有してみます。 記事内のコードは WPF で書いたものですが、WinRT でも一部を除いてほぼ同じようなコードで書けるはずです。

2015/04/12 追記:
@okazuki さんが、WinRT でやる場合の補足記事を書いてくださいました。
http://okazuki.hatenablog.com/entry/2015/04/11/195941

目標

今回は、Thumb コントロールを活用し、Photoshop のナビゲーターと同等のものを作ります。 ScrollViewer に表示されているコンテンツについて、以下を実現しましょう。

  • サムネイルの表示
  • Viewport (赤枠部分) の表示
  • 赤枠のドラッグで Viewport の位置変更

↓ みたいなやつ

Photoshop

最終的に完成したコードは、gist で公開しています。 WPF アプリケーションを作成し、MainWindow.xaml と MainWindow.xaml.cs を組み込めば実行できます。

ScrollViewer とか

大きなコンテンツを画面内に収めたいときは、ScrollViewer コントロールを使用しましょう。 表示領域よりもコンテンツのサイズの方が大きいとき、ScrollViewer はスクロール バーを表示して、可視領域を移動できるようになります。

このとき、実際に見えている領域のことを Viewport と呼びます。 それに対し、ScrollViewer 内の全域 (= コンテンツのサイズ) は Extent です。 それぞれ、ViewportWidth、ViewportHeight、ExtentWidth、ExtentHeight プロパティでサイズを取得できます。

余談ですが、ListBox などの ItemsControl 派生型は、内部で ScrollViewer を使用していることが多いですね。

ScrollViewer (WPF)
ScrollViewer (WinRT)

ひとまず、下記のように画像 (line 28-34) とサムネイル (line 19-21)、そして Viewport を示す Border (line 22-25) 配置する XAML コードを用意しました。


    
        
            
            
        

        
            
            
        

        
            
        
    

サムネイルと Viewport の表示

Bitmap

コンテンツ部分の Visual 要素をビットマップに書き込み、画像として表示する方法です。 WPF と WinRT でやり方が微妙に異なりますが、考え方は同じです。

using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace ThumbsScrollViewer
{
    public partial class MainWindow
    {
        public MainWindow()
        {
            this.InitializeComponent();

            // Bitmap をダウンロードして
            var image = new BitmapImage(new Uri("http://www.hitachinoki.net/download/wp/2015_ta.jpg"));
            // Image コントロールの Source に設定 (サムネイルはコントロール自体のサイズを小さくすればよい)
            this.Image.Source = this.Thumbnail.Source = image;
        }
    }
}

VisualBrush (WPF のみ)

別の方法もあります。 Brush 派生型の VisualBrush を使用し、コンテンツ部分の Visual 要素でサムネイル領域を塗りつぶすことで、縮小されたサムネイルを表現できます。 残念ながら WinRT には VisualBrush がないため、WPF 限定です。

Viewport

サムネイル上に ScrollViewer が表示している範囲 (Viewport) を示すためには、…手計算です! (説明省略

private void UpdateThumbnailViewport(object sender, ScrollChangedEventArgs e)
{
    // ExtentWidth/Height が ScrollViewer 内の広さ
    // ViewportWidth/Height が ScrollViewer で実際に表示されているサイズ

    var xfactor = this.Thumbnail.ActualWidth / e.ExtentWidth;
    var yfactor = this.Thumbnail.ActualHeight / e.ExtentHeight;

    var left = e.HorizontalOffset * xfactor;
    var top = e.VerticalOffset * yfactor;

    var width = e.ViewportWidth * xfactor;
    if (width > this.Thumbnail.ActualWidth) width = this.Thumbnail.ActualWidth;

    var height = e.ViewportHeight * yfactor;
    if (height > this.Thumbnail.ActualHeight) height = this.Thumbnail.ActualHeight;

    // Canvas (親パネル) 上での Viewport の位置を、Left/Top 添付プロパティで設定
    // (XAML で言う  みたいなやつ)
    Canvas.SetLeft(this.Viewport, left);
    Canvas.SetTop(this.Viewport, top);

    this.Viewport.Width = width;
    this.Viewport.Height = height;
}

この時点で、ScrollViewer 内に画像を表示し、画像のサムネイルを表示し、更に ScrollViewer で実際に見えている範囲を赤枠で表示することができました。

sample

CombinedGeometry

これは、WPF 限定です。

サムネイルと同じサイズの図形と、Viewport (赤枠) 部分と同じサイズの図形を、それぞれ RectangleGeometry で表現し、それを CombinedGeometry で結合させます。 その際、GeometryCombineMode=”Xor” を指定することによって、「Viewport 以外の部分のみを塗りつぶす」ことが可能です。

方法 : 結合したジオメトリを作成する
https://msdn.microsoft.com/ja-jp/library/ms746682(v=vs.110).aspx


    
    
        
            
        
    
    
public MainWindow()
{
    this.InitializeComponent();

    var image = new BitmapImage(new Uri("http://www.hitachinoki.net/download/wp/2015_ta.jpg"));
    image.DownloadCompleted += (sender, e) =>
    {
        // サムネイルを表示する Image コントロールのサイズで RectangleGeometry を作成し、
        // CombinedGeometry の 1 番目のジオメトリに設定
        var rect = new Rect(0, 0, this.Thumbnail.ActualWidth, this.Thumbnail.ActualHeight);
        this.CombinedGeometry.Geometry1 = new RectangleGeometry(rect);
    };

    this.Image.Source = this.Thumbnail.Source = image;
}
private void UpdateThumbnailViewport(object sender, ScrollChangedEventArgs e)
{
    // 中略...

    // Viewport を計算したとき、そのサイズで RectangleGeometry を作成し、
    // CombinedGeometry の 2 番目のジオメトリに設定
    this.CombinedGeometry.Geometry2 = new RectangleGeometry(new Rect(left, top, width, height));
}

sample2

Thumb コントロール

今回のミソである Viewport (赤枠) を、サムネイル上で動かせるようにしましょう。 もっとも簡単な方法は、Thumb コントロールを使用することです。

System.Windows.Controls.Primitives.Thumb (WPF)
Windows.UI.Xaml.Controls.Primitives.Thumb (WinRT)

Thumb コントロールは、ユーザーがドラッグによってコントロールを動かす機能を簡単に実現するためのものです。 これは、ScrollBar や Slider でユーザーがドラッグする部分 (“つまみ” の部分) で使われているものです。

DragStarted、DragCompleted、および DragDelta イベントを使用し、それぞれドラッグの開始と終了、ドラッグの移動量を検知することができます。 今回のサンプルでは、赤枠部分を Thumb コントロールに置き換え、DragDelta イベントを使用し移動量を ScrollViewer に反映します。 Thumb コントロールは他のコントロールと同様に ControlTemplate を使用し外観をカスタマイズできるため、赤枠は Thumb の外観として表現します。


    
    
        
            
                
            
        
    
private void OnDragDelta(object sender, DragDeltaEventArgs e)
{
    this.ScrollViewer.ScrollToHorizontalOffset(
        this.ScrollViewer.HorizontalOffset + (e.HorizontalChange * this.ScrollViewer.ExtentWidth / this.Thumbnail.ActualWidth));

    this.ScrollViewer.ScrollToVerticalOffset(
        this.ScrollViewer.VerticalOffset + (e.VerticalChange * this.ScrollViewer.ExtentHeight / this.Thumbnail.ActualHeight));
}

これで完成です!

viewport

まとめ

実は、1 年前に「Primitive コントロール探訪」という形で Controls.Primitives 名前空間以下の便利コントロールを紹介しようと書きかけていた記事の一つだったりします (忙しくて頓挫してますが!)。

今回は、@kurosawa0626 さんのリクエストで復活しました。 ありがとうございます (?)。

前述のとおり、Thumb コントロールは、ScrollBar や Slider の “つまみ” の部分で利用されているものです。 うまく活用すれば、今回のようにドラッグでコントロールを動かしたい場合や、リサイズ処理等に応用できます。 使ってみてください!

今回のサンプルは、gist で公開しています。 WPF アプリケーションを作成し、MainWindow.xaml と MainWindow.xaml.cs を組み込めば実行できます。
https://gist.github.com/Grabacr07/988bc04fb7f16aaa4fdc

WPF 4.6 での修正点、透明な子ウィンドウや High DPI 改善について

$
0
0

Japan C# Users Group の Visual Studio 2015 リリース記念勉強会 にて、.NET Framework 4.6 で WPF に加わった修正点について発表しました。 大きな変更はなく、細かい機能追加やバグ修正程度なのですが、2つ程ピックアップして紹介しています。

Transparent Child Window support

WPF の子ウィンドウが透過できるようになります。

そもそも子ウィンドウをどういうときに使うのか… というと、Win32 相互運用シナリオや、MDI を実現したいケースでしょうか。

Win32 相互運用、つまり WPF ウィンドウ内に HwndHost (WebBrowser や WindowsForms コントロール、Win32 要素など) を表示するとき、MSDN にも記述がある通り、様々な制約があります。 特に、「HwndHost は、同じトップレベル ウィンドウの他の WPF 要素の上に表示されます。」というのが曲者で、ZIndex 等を一切無視し、HwndHost が一番上に表示されるため、その上に WPF 要素を表示することはできません。

Browser (HwndHost) は必ず全要素の上に描画されてしまう

ウィンドウ ハンドルを 1 つしか持たない WPF アプリと、それぞれの要素がウィンドウ ハンドルを持つ GDI 等の旧来のテクノロジーは、まさに水と油のような関係であり、仕組み上仕方ないことではあります。 そこで、WebBrowser や過去の資産を HwndHost 上で表示し、さらにその上に WPF 要素を表示したいとき、独立したウィンドウ ハンドルを持つ子ウィンドウはその解決方法のひとつとなります (他には、System.Windows.Controls.Primitives.Popup などが使えたり)。

(ちなみに Win32 相互運用についてはいくらかノウハウを持っているのですが、個人的にこれ以降使う気がしないので、ブログにまとめていません… もし知りたい方がいらっしゃれば @Grabacr07 に圧力をかけまくるとそのうち書き始めるかもしれません…)

また、WPF は MDI をサポートしていません。 個人的には MDI は好みではない (自宅も職場もトリプル モニター、というマルチ モニター厨なので相性が悪い) ものの、もし実装する場合は、子ウィンドウが役に立つかもしれません。

子ウィンドウを作るコードの例を示します。 一般的な WPF アプリ開発において HwndSourceParameters を使って HwndSource を自作するようなシナリオは殆どなさそうなので、見慣れないかもしれません。

//                WS_CHLID, WS_CLIPCHILDREN, WS_VISIBLE
var styleParams = 0x40000000 | 0x02000000 | 0x10000000;```

var parentWindowHandle = new WindowInteropHelper(this).Handle;
var windowParams = new HwndSourceParameters("ChildWindow")
{
    ParentWindow = parentWindowHandle,
    WindowStyle = styleParams,
    PositionX = 200,
    PositionY = 150,
};

var child = new Border
{
    Width = 400,
    Height = 400,
    Background = new SolidColorBrush(Color.FromArgb(255, 64, 64, 64)),
};
var source = new HwndSource(windowParams) { RootVisual = child, };

サンプル コード全体は後ほどアップデートするかも。

実行して「Create child」ボタンを押すと、このような MDI 子ウィンドウっぽいものが表示されます。 .NET Framework 4.5.2 またはそれ以前をターゲットにしているアプリでは、17 行目の Background で Alpha に 128 等を指定して透過させようとしても、透明になりません (黒くなるだけ)。

MDI のようなもの (非透過)

そこで、アプリケーションのターゲット フレームワークを .NET Framework 4.6 にした上で、HwndSourceParameters のオブジェクト初期化子に以下を追加します。 また、背景を Alpha に 128 を指定して子ウィンドウのルート要素を透過させてみます。

var windowParams = new HwndSourceParameters("ChildWindow")
{
    ParentWindow = parentWindowHandle,
    WindowStyle = styleParams,
    PositionX = 200,
    PositionY = 150,
    UsesPerPixelTransparency = true,
};
var child = new Border
{
    Width = 400,
    Height = 400,
    Background = new SolidColorBrush(Color.FromArgb(128, 64, 64, 64)),
};

実行すると以下のように。 子ウィンドウが透過できているのが判るかと思います。

透過した子ウィンドウ

この HwndSourceParameters.UsesPerPixelTransparency プロパティが、.NET Framework 4.6 で追加されたメンバーです。 true を指定すると、上記のように WPF 子ウィンドウが透過できるようになります。

これは、WebBrowser 等の HwndSource に対しても、透過したうえでその上に表示することができます。 よって、先述の Win32 相互運用シナリオにおける HwndHost の前後関係の問題にも寄与できるでしょう。

注意しなければならないのが、この機能を使うためには、アプリケーション マニフェストで Windows 8 向けのビルドであることを明示する必要があるということです。


  
    

    
    

    
    

    
    

    
    

    
    

  

app.manifest を作ると、上記のようなコードが最初から記述されているはずです。 ここで、Windows 8 のコメントを外すだけです。

余談ですが、app.manifest での指定をせずに UsesPerPixelTransparency=”True” を使用すると、Win32Exception がスローされて表示できません。

High DPI Improvements

Hight DPI 環境下におけるレイアウトの丸め処理が改善されました。

元々どんな問題があったかというと、私の MetroRadiance がモロにその影響を受けていて、こちらの記事 で指摘して頂いています。 この場合のレイアウト システムの詳細な動作については把握できていませんが、1 dip (デバイス非依存ピクセル) を指定したとき、DPI 150 % 環境下で Round(1 * 1.5) = 2 になるはずが、1 になることがあるようです。

.NET Framework 4.6 では、このような BorderThickness や Margin のレイアウトの丸め処理が改善されており、ズバり上記のような問題が発生しなくなりました。 1 枚目が .NET Framework 4.5.2 での実行結果、2 枚目が 4.6 での実行結果です。

Bottom だけ 1px になってる。超ダサい。 すべて 2px に。キレイ。

この改善は、.NET Framework 4.6 をターゲットにしたアプリであれば、特にコードの修正等は必要なく、既定で適用されます (要はバグ修正的なノリなんですかね)。 また、.NET Framework 4.5.2 またはそれ以前をターゲットにしたアプリでも、.NET Framework 4.6 がインストールされている環境であれば、app.config に以下のようなコードを記述することで、この改善を受けることができます。



  
    
  

Switch.MS.Internal.DoNotApplyLayoutRoundingToMarginsAndBorderThickness (長ぇ)。

そして、High DPI に関しては、上記の改善のほかに “Multi-DPI setup” 環境下での改善も入っているようです (Per-Monitor DPI のこと?)。 ブラックアウトする問題が修正されているそうですが、そういう事象に遭遇したことがないので、よくわからず…。 調査中。

Visual Studio 2015 での新機能

WPF 開発で使える Visual Studio 2015 の新機能をちょこっと紹介。

Peek Definition と CodeLens はセッション資料にある通りです。 Live VisualTree と Live Property Explorer は、アプリ実行中に VisualTree の参照や各要素のプロパティ値の修正を可能にするものです。 Web 開発におけるブラウザーの開発者ツールのような感覚です。

実行中にガイドを表示しながら要素を選択して…

ガイドを表示しながら要素を選択(画像クリックで GIF アニメーション)

Live VisualTree で要素を選択したりデザイナー上に一発で呼びだしたり…

要素選んだりデザイナーで表示したり(画像クリックで GIF アニメーション)

Live Property Explorer で実行中にプロパティを書き替えたり。

プロパティを書き替え(画像クリックで GIF アニメーション)

すばらすぃ。 アプリのデバッグ効率が飛躍的に向上します。 まじ最強。

まとめ

という感じでした。 他にもタッチ サポートが改善されたりしているようですが、バグ修正っぽいのでスルーしています。

何か判ったことがあれば追記します。

Windows 10 の仮想デスクトップを制御しようとして失敗した話

$
0
0

Windows 10 で仮想デスクトップ機能が加わり、個人的にそこそこ活用しているつもりなのですが、残念な点が 1 つ。 アクティブ ウィンドウごと仮想デスクトップを移動したいことが結構あるのですが、それに対するショートカット キーがありません。

仮想デスクトップ間の移動は Ctrl + Win + 左右キー ですが、ウィンドウも一緒に、となると基本的にはマウス操作を伴います (しかも結構面倒なやつ)。 一応、マウス使わずにやる方法もあるようですが、Ctrl + Win + 左右キー に近い手軽なものがほしいです… (フィードバック済み)

で、公式で実装されるまでは自分でアプリ作るか、と思って Windows 10 の仮想デスクトップ周りの API を調べ始め、最終的に頓挫しました。 具体的には、以下のような状況です。

  • アプリ (C# コード) から仮想デスクトップの作成、削除、移動はできた
  • 同一プロセス内のウィンドウを、任意の仮想デスクトップに移動させることはできた
  • 他プロセスのウィンドウは、他の仮想デスクトップに移動させることはできなかった

一番やりたかったのが「グローバル キーフックを使って現在アクティブなウィンドウを別な仮想デスクトップに飛ばすアプリ」だったのですが、3 番目の理由により現時点では実現できませんでした。 ざんねん。

しかしまあ、せっかく調べた&仮想デスクトップ関連 API の C# ラッパー的なものを作ったので、公開しておきます。 だいたいここ見ながら作りました:

2015/09/14 追記:
最終的に完成しました。
http://grabacr.net/archives/5701

IVirtualDesktopManager

という、COM interface が追加されています。 Windows SDK for Windows 10 をインストールした環境で、C:\Program Files (x86)\Windows Kits\10\Include\10.0.10240.0\um\ShObjIdl.h あたりに定義されてるっぽい。

なので、以下のように。

[ComImport]
[Guid("a5cd92ff-29be-454c-8d04-d82879fb3f1b")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IVirtualDesktopManager
{
    bool IsWindowOnCurrentVirtualDesktop(IntPtr topLevelWindow);

    Guid GetWindowDesktopId(IntPtr topLevelWindow);

    void MoveWindowToDesktop(IntPtr topLevelWindow, ref Guid desktopId);
}

一方、VirtualDesktopManager class は CSLID “aa509086-5ca9-4c25-8f95-589d3c07b48a” で定義されています。 ですので、インスタンスは次のように作成します。

public static IVirtualDesktopManager GetVirtualDesktopManager()
{
    var vdmType = Type.GetTypeFromCLSID(CLSID.VirtualDesktopManager);
    var instance = Activator.CreateInstance(vdmType);

    return (IVirtualDesktopManager)instance;
}

Windows 10 の仮想デスクトップは GUID を識別子として管理されているようですね。 しかし、この I/F で実現できる機能は、指定したウィンドウ ハンドルのトップレベル ウィンドウに対して

  • 現在の仮想デスクトップ上にいるかどうかを判定
  • 属している仮想デスクトップの ID を取得
  • 指定した ID の仮想デスクトップへ移動

であり、そもそも今仮想デスクトップがいくつあるかを判断したり、仮想デスクトップ自体を追加・削除したりするのは難しそうです。

そこで、以下を使います。

IVirtualDesktopManagerInternal

MSDN には載ってません (先のロシア語の記事はどこからこれ持ってきたんだろう、、、)。 で、以下のようなメンバーらしい、です。

[ComImport]
[Guid("af8da486-95bb-4460-b3b7-6e7a6b2962b5")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IVirtualDesktopManagerInternal
{
    int GetCount();

    void MoveViewToDesktop(object pView, IVirtualDesktop desktop);

    bool CanViewMoveDesktops(object pView);

    IVirtualDesktop GetCurrentDesktop();

    IObjectArray GetDesktops();

    IVirtualDesktop GetAdjacentDesktop(IVirtualDesktop pDesktopReference, AdjacentDesktop uDirection);

    void SwitchDesktop(IVirtualDesktop desktop);

    IVirtualDesktop CreateDesktopW();

    void RemoveDesktop(IVirtualDesktop pRemove, IVirtualDesktop pFallbackDesktop);

    IVirtualDesktop FindDesktop(ref Guid desktopId);
}

[ComImport]
[Guid("ff72ffdd-be7e-43fc-9c03-ad81681e88e4")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IVirtualDesktop
{
    bool IsViewVisible(object pView);
    
    Guid GetID();
}

public enum AdjacentDesktop
{
    LeftDirection = 3,
    RightDirection = 4
}

こちらは、インスタンスを取得するのが若干面倒で、ImmersiveShell (IServiceProvider) 経由で以下のように。

public static IVirtualDesktopManagerInternal GetVirtualDesktopManagerInternal()
{
    var shellType = Type.GetTypeFromCLSID("c2f03a33-21f5-47fa-b4bb-156362a2f239");
    var shell = (IServiceProvider)Activator.CreateInstance(shellType);

    object ppvObject;
    shell.QueryService("aa509086-5ca9-4c25-8f95-589d3c07b48a", typeof(IVirtualDesktopManagerInternal).GUID, out ppvObject);

    return (IVirtualDesktopManagerInternal)ppvObject;
}

これを使えば、

  • 仮想デスクトップの数を取得
  • 現在表示されている仮想デスクトップを取得
  • すべての仮想デスクトップを取得
  • 指定した仮想デスクトップの隣の仮想デスクトップを取得
  • 指定した仮想デスクトップに切り替え
  • 仮想デスクトップを作成
  • 仮想デスクトップを削除
  • 指定した ID の仮想デスクトップを検索

ができるようになります。
これらを C# でラップしたライブラリを作ってあるので、サクっと触りたい方はこちらを使ってみてください。

https://github.com/Grabacr07/VirtualDesktop

注意しなければならないのが、ビルドによって IVirtualDesktopManagerInternal の IID が変わっているらしい、ということ。 10240 では “af8da486-95bb-4460-b3b7-6e7a6b2962b5″ ですが、10130 では “ef9f1a6c-d3cc-4358-b712-f84b635bebe7″ だった、らしい。。。 (つまり、今動いても次のビルドで動かなくなる、的なことが発生しそう)

ちなみに、ロシア語のブログの方には、IApplicationView というインターフェイスがでてきます。 WinRT の内部にある internal なインターフェイスです (確か Windows.UI だった気がする)。 使い方わからない。

WinRT アプリ内から IVirtualDesktopManager 叩けるかどうか、確かめて下さる方お願いしまうs

MoveWindowToDesktop が動かない

GitHub で公開してるラッパーには簡単なサンプルを付属させています。 仮想デスクトップ間の移動や、ウィンドウごと移動したりする挙動は、そのサンプルで確認できます。

ウィンドウを別の仮想デスクトップへ移動させるためには、IVirtualDesktopManager.MoveWindowToDesktop(IntPtr, GUID) を使います。 しかしながら、この関数は「同一プロセス内のハンドルでないと動かない」ようです。

アクセス拒否

なので、アプリ内で、自分のアプリが表示したウィンドウを別の仮想デスクトップに移動させることはできます。 一方で、別のプロセスが表示しているウィンドウは、仮想デスクトップ間を移動させることはできません。 今後できるようになるかも不明 (無理な気がしている)。

まとめ

ということで、仮想デスクトップに対する操作ができるようになるライブラリ作りました。 が、肝心の部分が動かないので使う機会は少なそう。

クラシック デスクトップ アプリの Windows テーマ追従

$
0
0

Windows には、パーソナライズの一部としてテーマ設定が存在し、「アクセント カラー」を選択することができます。 また、Windows 10 build 14316 から、アクセント カラーとは別に「app mode」として Light/Dark テーマを選択できるようになりました。

個人設定

Light/Dark

UWP アプリは SystemAccentColor などの一部のリソースを ThemeResource として指定することで、Windows のアクセント カラーやテーマによって決定される色に追従することが可能です。

一方で、クラシック デスクトップ アプリにおいては、例えば WPF ではそのようなリソースが定義されておらず、フレームワークとして Windows テーマに追従する機能は実装されていません。 今回は、クラシック デスクトップ アプリにおいて、Windows のテーマ設定に追従する方法について解説します。

ちなみに余談ですが、私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。

アクセント カラーの取得

アクセント カラーは DwmGetColorizationColor 関数を使用して取得します。 マネージ コードで書く場合は以下のように宣言しましょう。

public static class Dwmapi
{
    [DllImport("Dwmapi.dll")]
    public static extern void DwmGetColorizationColor([Out] out int pcrColorization, [Out] out bool pfOpaqueBlend);
}

第一引数でアクセント カラーが返ってきます。 整数型で 0xAARRGGBB のフォーマットになっていますので、以下のようにして Color 型にすると良いでしょう。

public static Color GetColorFromInt64(long color)
{
    return Color.FromArgb((byte)(color >> 24), (byte)(color >> 16), (byte)(color >> 8), (byte)color);
}

この Color 型をもとに、例えば WPF アプリでは SolidColorBrush を生成すれば、クラシック デスクトップ アプリでも Windows のアクセント カラーを取得してアプリに反映することができます。

ちなみに余談ですが、私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。

テーマ カラーの取得

前述のとおり、Windows 10 build 14316 から「app mode」として Light/Dark テーマを選択できるようになりました。 しかしながら、それ以前のビルドでも、設定 UI が提供されていないだけでテーマ設定自体は可能でした。

設定値はレジストリに保存されており、以下のキーと値で指定されます。
キー: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize
値: AppsUseLightTheme

0 の場合は Dark テーマ、1 の場合は Light テーマが設定されています。 具体的にいつのビルド番号からは覚えていませんが、少なくとも Windows 10 build 10586 (4/27 時点でのリリース バージョン) では、上記レジストリ値を手動で設定することで、テーマ設定の変更が可能です (変更の適用にはサイン アウトが必要です)。

アクセント カラーと違い、テーマ設定は RGB 値などの具体的な色情報を持っていないため、Light/Dark テーマでどのような色を使用するかは、アプリ開発者が設計および実装する必要があります。

例えば、Visual Studio のように Light/Dark などのテーマ設定が存在するクラシック デスクトップ アプリにおいて、Windows のテーマ設定によって自動的に切り替えるような実装が可能となります (Visual Studio にはそのような機能は備わっていませんが…)。

ちなみに余談ですが、私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。

アクセント カラーの動的な変更に対する追従

これまでの内容は設定値を取得するまでで、アプリの起動中にユーザーがアクセント カラーやテーマ設定を変更した場合に対応できません。 このような動的な変更に対しては、ウィンドウ プロシージャで特定のウィンドウ メッセージに対する処理を記述することで可能となります。

アクセント カラーの変更を通知するウィンドウ メッセージは WM_DWMCOLORIZATIONCOLORCHANGED です。 wParam から 0xAARRGGBB のフォーマットで変更後の色が取得できます。

マネージ コードで書くなら、以下のようになるでしょう。

if (msg == (int)WindowsMessages.WM_DWMCOLORIZATIONCOLORCHANGED)
{
    var color = ColorHelper.GetColorFromInt64((long)wParam); // ↑ で定義したやつ

    // アクセント カラーの変更通知とか諸々

    handled = true;
}

この方法は、ウィンドウ メッセージを受信するために、1 つ以上のトップレベル ウィンドウが必要です。 WPF のウィンドウでウィンドウ プロシージャと同等のコードを実装する方法は、多くの方が既に解説されています

しかしながら、ウィンドウを持たないライブラリ等に上記の機能を実装する必要がある場合でも、以下のようにして不可視のウィンドウを生成し、メッセージを受信させることで対応可能です。

public void ShowTransparentWindow()
{
    var parameters = new HwndSourceParameters("")
    {
        Width = 1,
        Height = 1,
        WindowStyle = (int)WindowStyles.WS_BORDER,
    };
    var source = new HwndSource(parameters);
    source.AddHook(WndProc);
}

protected IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
    // ウィンドウ メッセージの受信ほげもげ
    return IntPtr.Zero;
}

HwndSource クラスは、WPF 向けに低レイヤーの Win32 機能を公開しており、HwndSourceParameters 構造体を使用してインスタンス化することで、不可視となるようなウィンドウ スタイルを指定して Win32 ウィンドウを作ることができます。

これらの手段により、クラシック デスクトップ アプリ、ならびにそのライブラリにおいて、動的に変更されるアクセント カラーを都度取得して対応することができます。

また WPF の場合、取得した色情報は SolidColorBrush としてリソース化しつつ設定変更を検知したら書き換えることにより、DynamicResource で参照すれば、UWP の ThemeResource と同様のことができます。

ちなみに余談ですが、私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。

テーマ カラーの動的な変更に対する追従

このセクションの内容は、Windows 10 build 14316 以降のみで使用できます。

前述のとおり、Windows 10 build 14316 ではテーマに対する設定 UI が提供され、リアルタイムで Light/Dark の切り替えが可能となりました。 アクセント カラーと同様に、テーマ設定の変更はウィンドウ メッセージで通知されるため、アクセント カラーと同じ方法で対応することができます。

テーマ設定の変更を通知するウィンドウ メッセージは WM_SETTINGCHANGE です。 lParam ならびに wParam で変更後の設定値は提供されないため、アクセント カラーと違ってメッセージを受信したら再度レジストリから取得する必要がある点に注意してください。

マネージ コードで書く場合は以下のようになるでしょう。

if (msg == (int)WindowsMessages.WM_SETTINGCHANGE)
{
    var systemParmeter = Marshal.PtrToStringAuto(lParam);
    if (systemParmeter == "ImmersiveColorSet")
    {
        // 再度レジストリから Dark/Light をとってくるとか

        handled = true;
    }
}

ちなみに余談ですが、私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。

MetroRadiance

今回の内容をすべて実装するのは骨が折れそうですし、例によって既にライブラリ化しておきました。

MetroRadiance に含まれる MetroRadiance.Core プロジェクトで、以下を実装しています。

  • アクセント カラーの取得
  • アクセント カラーの変更への追従
  • テーマ設定の取得
  • テーマ設定の変更への追従
  • ライブラリでウィンドウ メッセージ受信してあれこれ (HwndSource で不可視ウィンドウあれこれ) の例

同梱されているサンプル アプリ (MetroRadiance.Showcase) で UI をテーマ設定に追従させた様子が以下になります。 左側が自作アプリ、右側が Windows 10 14328 の設定アプリです。

wpf

実装例として参考にして頂ければと思います。 もしくは、NuGet から MetroRadiance.Core 2.2.0 以降をインストールして使ってみてください。

使用方法は GitHub の readme をご覧隊頂ければ。 また不明点や不具合等ありましたら @Grabacr07 までお願いします。

おわりに

私は Windows デスクトップ アプリが「クラシック」と表現されていることに納得していません。


デスクトップ アプリを Surface Dial に対応させる

$
0
0

MVP Global Summit 2016 で渡米した際、Microsoft のオールインワン PC “Surface Studio” とともに発表された Surface Dial を入手できたので、さっそくアプリから使ってみました。 アプリでの対応は簡単で、むしろ「どう使わせるか」のアイディア勝負になるデバイスという印象です。

発表時の映像での「Surface Studio の画面に置いて使う」インパクトが強いですが、デバイス自体は BLE (Bluetooth 4.0) で接続するシンプルなものです。 あくまで、Surface シリーズの場合は画面に置いて反応するというだけで、それ以外の PC でも机に置いて使うことができます。 また、今後同様のサードパーティ製品が発売されることも予想されます。

UWP アプリから使う

UWP API の Windows.UI.Input.RadialController という型を使用して、メニュー項目の操作や回転・クリック (押し込み) イベントを購読します。 エントリー ポイントは RadialController.CreateForCurrentView() という静的メソッドで、これが RadialController のインスタンスを取得する唯一の方法です。

また、既定のメニュー項目を削除したりする場合に Windows.UI.Input.RadialControllerConfiguration という型も使用します。

UWP、および Dial の基本的な実装方針に関しては下記エントリーが詳しいです。

http://blog.okazuki.jp/entry/2016/11/11/171706
https://blogs.msdn.microsoft.com/shintak/2016/11/15/dialprogramming/

WPF アプリから使う (基本)

.NET Framework の WPF や Windows Forms アプリでも、UWP アプリと同じ要領で Surface Dial に対応させることができます。 UWP API である RadialController を使用することになるため、プロジェクトに WindowsRuntime を参照させる必要があります。

UWP アプリにおける RadialController のエントリー ポイントが View であるように、デスクトップ アプリの場合はウィンドウ ハンドルです。 つまり、現時点において Surface Dial のコントローラーはウィンドウ単位で作る必要がある、ということです。

まず、RadialControllerRadialControllerConfiguration インスタンスを取得するため、… の COM オブジェクトを取得するためのインターフェイスを用意します。 これは Microsoft のサンプルで公開されている方法です。

[Guid("1b0535c9-57ad-45c1-9d79-ad5c34360513")]
[InterfaceType(ComInterfaceType.InterfaceIsIInspectable)]
public interface IDesktopRadialController
{
    RadialController CreateForWindow(IntPtr hWnd, [In] ref Guid iid);
}

[Guid("787cdaac-3186-476d-87e4-b9374a7b9970")]
[InterfaceType(ComInterfaceType.InterfaceIsIInspectable)]
public interface IDesktopRadialControllerConfiguration
{
    RadialControllerConfiguration GetForWindow(IntPtr hWnd, [In] ref Guid iid);
}

続いて以下は、上記インターフェイス経由で実際にインスタンスを取得するコードの例です。 このコードさえ用意してしまえば、デスクトップ アプリからも DesktopRadialController.Create(hWnd) のようなシンプルな呼び出しで Surface Dial のコントローラーを操作できるようになります。

public static class DesktopRadialController
{
    public static RadialController Create(IntPtr hWnd)
    {
        var controller = (IDesktopRadialController)WindowsRuntimeMarshal.GetActivationFactory(typeof(RadialController));
        var iid = typeof(RadialController).GetInterface("IRadialController").GUID;
        return controller.CreateForWindow(hWnd, ref iid);
    }
}

public static class DesktopRadialControllerConfiguration
{
    public static RadialControllerConfiguration Create(IntPtr hWnd)
    {
        var configration = (IDesktopRadialControllerConfiguration)WindowsRuntimeMarshal.GetActivationFactory(typeof(RadialControllerConfiguration));
        var iid = typeof(RadialControllerConfiguration).GetInterface("IRadialControllerConfiguration").GUID;
        return configration.GetForWindow(hWnd, ref iid);
    }
}

これ以降の手順は、メニュー項目のアイコンを独自に追加する場合 (後述) を除けば UWP と同じです。 WPF であれば HwndSource 経由でウィンドウ ハンドルを取得し、RadialController のインスタンスであれこれしましょう。

// ウィンドウ ハンドルから RadialController と RadialControllerConfiguration のインスタンス取得
var controller = DesktopRadialController.Create(source.Handle);
var configuration = DesktopRadialControllerConfiguration.Create(source.Handle);

// 既定メニューのうちアプリで使いたいものを選んで設定
configuration.SetDefaultMenuItems(new[] { RadialControllerSystemMenuItemKind.Volume, });

// アプリ独自メニューを追加
controller.Menu.Items.Add(RadialControllerMenuItem.CreateFromKnownIcon("Tab", RadialControllerMenuKnownIcon.Scroll));

// Dial の開店イベントを購読 (args.RotationDeltaInDegrees で回転角度を取ったりする)
controller.RotationChanged += (sender, args) => Action(args.RotationDeltaInDegrees);

さっそくですが、自分のプロダクト (KanColleViewer) を Surface Dial に対応させ、UI を操作できるようにしました。 タブを切り替えるだけの単純な機能ですが、Surface Dial の回転イベントを拾って切り替えています。 このコードは GitHub で公開しています

WPF アプリから使う (つらい)

RadialController に追加するメニュー項目は、既定で用意されているアイコンから選択するか、もしくは独自のアイコンを設定することができます。 先述の KanColleViewer の例は既定のアイコンから選択しました。

  • .CreateFromKnownIcon(): 既定のアイコンを RadialControllerMenuKnownIcon から選択
  • .CreateFromIcon(): 独自のアイコンを IRandomAccessStreamReference で指定

つまり、独自アイコンのメニュー項目を追加したい場合は、デスクトップ アプリの場合でも IRandomAccessStreamReference を用意する必要があります。

Microsoft のサンプルにあるとおり、ファイル システム上に存在する画像ファイルをアイコンとして使用する場合は、StorageFile.GetFileFromPathAsync(path)StorageFile を取得し、RandomAccessStreamReference.CreateFromFile(storage) で OK です。

一方で、アセンブリ内にリソースとして配置した画像ファイルをアイコンとして使用する場合は、少々面倒な操作が必要になります。 先にコードを示しておきます。

async void InitializeController()
{
    var controller = this.CreateRadialController();
    
    // 中略

    const string iconUri = "pack://application:,,,/SampleAssembly;Component/assets/Item0.png";
    var menuItem = await CreateMenuItem("Sample", new Uri(iconUri, UriKind.Absolute));

    controller.Menu.Items.Add(menuItem);
}

static async Task CreateMenuItem(string displayText, Uri iconUri)
{
    // リソースの URI からストリームを得る
    var resourceInfo = Application.GetResourceStream(iconUri);
    if (resourceInfo == null) throw new ArgumentException("Resource not found.", nameof(iconUri));

    using (var stream = resourceInfo.Stream)
    {
        // ストリームからバイト配列を得る
        var array = new byte[stream.Length];
        stream.Read(array, 0, array.Length);

        using (var randomAccessStream = new InMemoryRandomAccessStream())
        {
            // バイト配列を UWP のストリームに書き込む
            await randomAccessStream.WriteAsync(array.AsBuffer());
            return RadialControllerMenuItem.CreateFromIcon(displayText, RandomAccessStreamReference.CreateFromStream(randomAccessStream));
        }
    }
}

ご覧の通り、アイコン画像リソースの URI から Stream を作成し、バイト配列に読み込み、それを UWP の InMemoryRandomAccessStream に書き込む、という手順です。 ストリームへの書き込みで非同期操作が出てくるため、たかだかメニュー項目の作成で async/await している微妙っぷり (まあ、.AsTask().Wait() してしまえば同期的に書けるものの…)。

上記コードを動かす場合、Windows.Foundation.IAsyncOperation<T> を await するために System.Runtime.WindowsRuntime.dll の参照が必要です。 ちなみに同アセンブリには .NET Framework の Stream を IRandomAccessStream にするための .ToRandomAccessStream() なる便利拡張メソッドがあるのですが、その方法だとアイコンが表示されませんでした。つらい。

UWP であれば RandomAccessStreamReference.CreateFromUri(new Uri("ms-appx:///Assets/Item3.png")) のように URI を指定して一発で終わりなんですが、WPF アプリの場合はこの方法でしか追加できませんでした (ぐらばく調べ。もっといい方法があったら教えてください…)。 AppX 化したデスクトップ アプリなら ms-appx:// な URI でいけるんじゃないか、と思ってますが未確認。

Win32 アプリから使う

つらそう

おわりに

場合によって若干面倒なことになるものの、デスクトップ アプリでも簡単に Surface Dial に対応させることができる、ということを紹介しました。

重ねて言っておきますが、これはアイディア勝負です。 UWP でも WPF でも実装自体は簡単で、ユーザーにどれほど有用な操作を提供できるかが勝負になると思っています。 先の KanColleViewer に実装した例はあくまでサンプルであって、私自身あの機能自体が有用であるとは微塵も思っていません。

なので、近々アイディアソンをしたいな、と企んでいます。 が、その前に日本発売いつなんだろう…

WPF で PowerShell コンソールを作る

$
0
0

これは PowerShell Advent Calendar 2016 の 22 日目のエントリーです。

PowerShell と WPF (Windows Presentation Foundation) という単語が並んだ場合、多くの方は PowerShell から WPF ウィンドウ等の GUI を扱う方法について想像されると思いますが、本エントリーはその逆です。 即ち、WPF で PowerShell コンソールを作成する手段について扱います。

最終的に、以下のような形で自身のアプリケーション内にビルトインの PowerShell コンソールを実装し、コマンドレットやスクリプトの実行とそれに派生し独自データのフィルタリング等を行えるようにします (画像はクリックで拡大されます)。

posh in KanColleViewer

アプリケーション内にビルトイン 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 インスタンスを作成すれば、最低限の実行環境となります。 AddCommandAddScript メソッドでコマンドレットやスクリプトを追加して、Invoke メソッドを叩くだけです。

using (var powershell = PowerShell.Create())
{
    powershell.AddCommand("Get-ChildItem");
    powershell.AddCommand("Out-String");

    foreach (var result in powershell.Invoke())
    {
        Console.WriteLine(result);
    }
}

output1

ですので、入力ボックスで受け入れた 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();

output2

また 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);
    }
}

output3

冒頭で示した GIF 画像は艦これのツールですが、保持しているすべての艦娘 (キャラクター) インスタンスを取得するコマンドレット Get-Ships 作って呼び出すことにより、艦娘のフィルターを書けるようにしています。 ここで書いたコードはごく簡単な例であり、System.Management.Automation.dll のより込み入った扱い方は他のブログ等を参照して頂ければと思います。

WPF でコンソール

続いて、コンソールの GUI の作り方です (「コンソールの GUI」ってお前は何を言っているんだ感)。 数ヶ月前に Metro.cs #2 で登壇した際、以下の資料の後半で WPF におけるコントロールの選択基準についてお話しました。

要するに、WPF においては「そのコントロールが提供する機能」に着目すべきであり、テンプレートで自由に作り変えられる外観はコントロールの選択に影響しない、ということです。 余談ですが、この資料、アップロードだけして非公開になっており、それに気付かず数ヶ月放置していたという失態を犯していました。ジャンピング土下座

つまり、WPF における GUI の実装においては、実現したい対象の挙動を分解することで適切なコントロールを選択すると良いです。 実はここが本エントリーで最も主張したかった内容となります。 コンソールを作るにあたり、その構成要素を観察すると「出力」「プロンプト」「入力エリア」の 3 要素があることが判ります。 また、ユーザーから文字入力を受け付けるのはキャレットが「入力エリア」にあるときのみで、「出力」「プロンプト」は文字入力を受け付けず、入力しようとするとキャレットを入力エリアに飛ばす挙動をとります。

console

つまり、キャレットが「入力エリア」にあるかどうかで文字入力の挙動を変えてやればいいわけで、TextBox.IsReadOnly のようにコントロールの機能で読み取り専用属性を付与させなくとも、緻密なキャレット制御により再現可能です。 以上を踏まえ、コンソールの GUI 要件を整理すると以下のようになり、これら実現できればコンソールとしての動作が可能になると言えます。

  • 文字列の入力
  • 文字列の出力
  • 文字列の装飾
  • キー入力制御
  • キャレット制御

実は、これらを実現できる便利な標準コントロールがあります。 そう、RichTextBox です。 今回は、完全に自前実装するよりリーズナブルな方法として RichTextBox から派生したコントロールを作り、コンソール相当の GUI を実装しました。

サンプル コードは下記リポジトリで公開しています (ただのサンプルなのに名前が仰々しすぎる)。 https://github.com/Grabacr07/AvalonShell

posh2

単一エントリー内で解説するには巨大な量になってしまったので、詳細はリポジトリ内のコードをご覧いただくとして、以降は 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 になります。

console2

また、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;
}

posh3

おわりに

PowerShell コンソールを作ろうと思ったのは、私の自作アプリ (KanColleViewer) の一覧系の画面においてビルトインで提供しているフィルターだけでは不足になることがあり、ユーザーがカスタム フィルターを書けるようにしたいと思ったのが発端です。 本来であれば独自クエリや C# Scripting などで対応すべきところだとは思いますが、コンソールの実装自体は WPF の GUI 開発のネタとして面白いかな、と考え作ってみた次第です。

公開しているサンプルは、現時点では (時間の都合で) 完全にコンソールを再現しているとは言えず、例えば Ctrl+A で全選択して Delete キーを押すとぜんぶ消せてしまったりします。 こういった細かな穴を埋めていけば、最終的に PowerShell ISE などのようなビルトイン PowerShell コンソールを実現できるでしょう。