⌨️

カスタマイズ可能なショートカット割り当てをWindowsアプリケーションに実装する

2021/07/23に公開

ハマった音ゲーっぽい譜面作成ツールを作っていたら違う音ゲーに使われていました。
ぱらつりといいます。
https://twitter.com/i/events/1314753579849113600

今回は拙作のChedに実装したカスタムショートカット割り当てについての記事になります。

はじめに

さて、ショートカットといえば普段から当たり前に使うものですが、Chedではファイルのオープンや保存、元に戻すというような大体のアプリケーションで共通となる部分にしかショートカットを割り当てていませんでした。
普通のテキスト編集とは異なり、譜面を構成するオブジェクトに対する操作という独特な部分に対して、人によって好みは異なるだろうと考えていたためです。
とりあえずエイヤで割り当てても良いとは思いますが、それはそれで押し付けになる部分もあると思い、先送りにしていました。

世の中やりたいことを調べれば多くのことは解決策が見つかりますが、自分の場合ユーザが設定を変更できるショートカット割り当ての手法が調べても調べても見つかりませんでした。
キーを決め打ちしてKeyDownで拾うというところまでしか見つけられず、でもVisual Studioとかクリスタとか普通に使えるアプリケーションもあるのになあというところで、自分で実装するしかないと思い至りました。

ちなみに、現在のChedはWinforms+WPFのキメラという何とも言えない状況になっています。
これは譜面を描画する部分で雑にカスタムコントロール描画をしていたものの、設定画面のUIなんかは宣言的に記述したくなったのでつまみ食いし始めた結果ですが、若干特殊な環境である点だけご承知おきください。

インターフェース設計とShortcutManager

今回はいくつかの要素に分けてカスタムショートカット実行を実装します。
まず1つは実行可能なアクションの実体を保持するIShortcutCommandSourceインターフェースです。
実行できるアクションをコマンドとし、与えられたコマンドを実行するExecuteCommandメソッドとコマンドの表示名を取得するResolveCommandNameメソッドを定義しています。
それぞれのメソッドは与えられたコマンドが存在しなければfalseを返す仕様とします。

また、IShortcutCommandSourceを実装する「何もしない」コマンドソースとして、NullShortcutCommandSourceクラスを定義しておきます。

public interface IShortcutCommandSource
{
    IEnumerable<string> Commands { get; }

    /// <summary>
    /// 指定のコマンドを実行します。
    /// </summary>
    /// <param name="command">実行するコマンド</param>
    /// <returns>コマンドが実行された場合はTrue</returns>
    bool ExecuteCommand(string command);

    bool ResolveCommandName(string command, out string name);
}

public class NullShortcutCommandSource : IShortcutCommandSource
{
    public IEnumerable<string> Commands => Enumerable.Empty<string>();

    // Do nothing
    public bool ExecuteCommand(string command) => false;

    public bool ResolveCommandName(string command, out string name)
    {
        name = null;
        return false;
    }
}

次にショートカットと実行するコマンドを対応付けるIShortcutKeySourceインターフェースを定義します。
ここでのKeysSystem.Windows.Forms.Keysとなっていて、キーに対応するコマンドを取得するResolveCommandメソッドとコマンドに対応するキーを取得するResolveShortcutKeyメソッドを実装します。
後者は割り当てたキーをメニューバーに表示するために利用します。

こちらも同様に、IShortcutKeySourceを実装する「何もしない」キーソースとしてNullShortcutKeySourceクラスを定義しておきます。

public interface IShortcutKeySource
{
    /// <summary>
    /// 指定のショートカットキーに対応するコマンドを取得します。
    /// </summary>
    /// <param name="key">コマンドを取得するショートカットキー</param>
    /// <param name="command">ショートカットキーに対応するコマンド</param>
    /// <returns>ショートカットキーに対応するコマンドが存在すればTrue</returns>
    bool ResolveCommand(Keys key, out string command);

    /// <summary>
    /// 指定のコマンドに対応するショートカットキーを取得します。
    /// </summary>
    /// <param name="command">ショートカットキーを取得するコマンド</param>
    /// <param name="key">コマンドに対応するショートカットキー</param>
    /// <returns>コマンドに対応するショートカットキーが存在すればTrue</returns>
    bool ResolveShortcutKey(string command, out Keys key);
}

public class NullShortcutKeySource : IShortcutKeySource
{
    public bool ResolveCommand(Keys key, out string command)
    {
        command = null;
        return false;
    }

    public bool ResolveShortcutKey(string command, out Keys key)
    {
        key = Keys.None;
        return false;
    }
}

以上のインターフェースを実装したインスタンスに対して、キー入力から実際のアクションを実行するShortcutManagerクラスを以下のように作成します。
このクラスではキー入力から割り当てられたショートカットを実行するExecuteCommandメソッド、コマンドから対応するショートカットキーを取得するResolveShortcutKeyメソッドを実装しています。

ここで、IShortcutKeySourceはアプリケーション側でデフォルトとして定義するショートカットを表すインスタンスと、ユーザ定義のショートカットを表すインスタンスの2つを保持しています。
ユーザ定義、アプリケーション定義の順にIShortcutKeySourceに問い合わせ、ユーザ定義のショートカットが優先して実行されるよう実装しています。

public class ShortcutManager
{
    public event EventHandler ShortcutUpdated;

    public IShortcutCommandSource CommandSource { get; set; } = new NullShortcutCommandSource();
    public IShortcutKeySource DefaultKeySource { get; set; } = new NullShortcutKeySource();
    public IShortcutKeySource UserKeySource { get; set; } = new NullShortcutKeySource();

    public bool ExecuteCommand(Keys key)
    {
        bool Resolve(IShortcutKeySource source)
        {
            if (source.ResolveCommand(key, out string command))
            {
                return CommandSource.ExecuteCommand(command);
            }
            return false;
        }

        // User, Defaultの順にトラバースしてひっかける
        return Resolve(UserKeySource) || Resolve(DefaultKeySource);
    }

    public bool ResolveShortcutKey(string command, out Keys key)
    {
        return UserKeySource.ResolveShortcutKey(command, out key) || DefaultKeySource.ResolveShortcutKey(command, out key);
    }

    public void NotifyUpdateShortcut() => ShortcutUpdated?.Invoke(this, EventArgs.Empty);
}

ユーザ定義ショートカットとデフォルトショートカット

IShortcutKeySourceの実装として、ユーザー定義のショートカットとデフォルトのショートカットを表す2つのクラスを作成します。
が、内部でのショートカットとコマンドのマッピングの持ち方は基本的に同様なので、この部分を実装したベースクラスを先に作成しておきます。
ここではショートカットキーとコマンドの対応付けを保持する辞書に対して、新たにショートカット定義を追加、削除するRegisterShortcut, UnregisterShortcutメソッドをサブクラスに公開し、IShortcutKeySourceのメソッドをあわせて実装しています。

public abstract class ShortcutKeySource : IShortcutKeySource
{
    private Dictionary<Keys, string> KeyMap { get; } = new Dictionary<Keys, string>();
    private Dictionary<string, HashSet<Keys>> CommandMap { get; } = new Dictionary<string, HashSet<Keys>>();

    public IEnumerable<Keys> ShortcutKeys => KeyMap.Keys;

    public ShortcutKeySource()
    {
    }

    protected ShortcutKeySource(ShortcutKeySource other)
    {
        foreach (var item in other.KeyMap)
        {
            RegisterShortcut(item.Value, item.Key);
        }
    }

    protected void RegisterShortcut(string command, Keys key)
    {
        // キーの重複を確認。コマンドは重複してもよい(異なるキーから同じコマンドを呼び出しても問題ない)
        if (KeyMap.ContainsKey(key)) throw new InvalidOperationException("The shortcut key has already been registered.");
        KeyMap.Add(key, command);
        if (!CommandMap.ContainsKey(command)) CommandMap.Add(command, new HashSet<Keys>());
        CommandMap[command].Add(key);
    }

    protected void UnregisterShortcut(Keys key)
    {
        if (!KeyMap.ContainsKey(key)) throw new InvalidOperationException("The shortcut key is not registered.");
        string command = KeyMap[key];
        KeyMap.Remove(key);
        CommandMap[command].Remove(key);
    }

    public bool ResolveCommand(Keys key, out string command) => KeyMap.TryGetValue(key, out command);

    public bool ResolveShortcutKey(string command, out Keys key)
    {
        key = Keys.None;
        if (!CommandMap.TryGetValue(command, out HashSet<Keys> keys)) return false;
        if (keys.Count > 0)
        {
            key = keys.First();
            return true;
        }
        return false;
    }
}

続いてアプリケーションでのデフォルトのショートカットを表すDefaultShortcutKeySourceを定義します。
ここで参照しているCommands.XXXstaticクラスにただのプロパティとして定義しているものです。(VSCodeの形を踏襲しているのはひみつ……)

public class DefaultShortcutKeySource : ShortcutKeySource
{
    public DefaultShortcutKeySource()
    {
        RegisterShortcut(Commands.NewFile, Keys.Control | Keys.N);
        RegisterShortcut(Commands.OpenFile, Keys.Control | Keys.O);
        RegisterShortcut(Commands.Save, Keys.Control | Keys.S);
	// その他利用可能なショートカット定義
        RegisterShortcut(Commands.ShowHelp, Keys.F1);
    }
}

ユーザー定義ショートカットを表すUserShortcutKeySourceクラスでは、外部からショートカットを登録できるRegisterShortcutメソッドと、設定を読み込んだり、設定を保存できるようにするメソッドを定義しておきます。
設定の永続化にはNewtonsoft.Jsonを利用していますが、この部分についての詳細は割愛します。

public class UserShortcutKeySource : ShortcutKeySource
{
    public UserShortcutKeySource()
    {
    }

    public UserShortcutKeySource(string jsonText)
    {
        var shortcuts = Newtonsoft.Json.JsonConvert.DeserializeObject<List<ShortcutDefinition>>(jsonText);
        foreach (var item in shortcuts)
        {
            RegisterShortcut(item.Command, item.ShortcutKey);
        }
    }

    public UserShortcutKeySource(UserShortcutKeySource other) : base(other)
    {
    }

    public new void RegisterShortcut(string command, Keys key)
    {
        base.RegisterShortcut(command, key);
    }

    public new void UnregisterShortcut(Keys key)
    {
        base.UnregisterShortcut(key);
    }

    public string DumpShortcutKeys()
    {
        var binds = ShortcutKeys.Select(p =>
        {
            if (!ResolveCommand(p, out string command)) throw new InvalidOperationException();
            return new ShortcutDefinition(command, p);
        });
        return Newtonsoft.Json.JsonConvert.SerializeObject(binds, Newtonsoft.Json.Formatting.Indented);
    }

    public UserShortcutKeySource Clone() => new UserShortcutKeySource(this);
}

実際に引っかける

まずはDefaultShortcutKeySourceのインスタンスを使ってShortcutManagerのインスタンスを初期化します。

var commandSource = new ShortcutCommandSource();
commandSource.RegisterCommand(Commands.OpenFile, "開く", OpenFile);

var shortcutManager = new ShortcutManager()
{
    DefaultKeySource = new DefaultShortcutKeySource(),
    CommandSource = commandSource
};

フォームのProcessCmdKeyをオーバーライドして以下のように引っかけます。

class MyForm : Form
{
    private ShortcutManager ShortcutManaget { get; }

    protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
    {
        if (ShortcutManager.ExecuteCommand(keyData)) return true;
        return base.ProcessCmdKey(ref msg, keyData);
    }
}

ここまでで、実際にデフォルトとして定義したショートカットが動作できる状態になりました。
別に保存した設定からUserShortcutKeySourceを作成し、ShortcutManager.UserShortcutKeySourceに割り当てることでユーザ定義のショートカットも利用できるようになります。

キー割り当て変更画面

いよいよユーザ定義のショートカットを割り当てる画面を作ります。
この画面についてはWPFで作成し、以下のようにコントロールを配置します。
選択中のコマンドに対して、実際にキーを叩くことでそのコンビネーションを割り当てます。

初期化はデフォルトのショートカット定義のみに戻すボタンですが、ShortcutManagerの実装によりUserShortcutKeySourceをクリアすれば反映できるようになっています。

ビューモデルの全体については以下より参照できます。(コードビハインドに書くという雑さはありますが)
https://github.com/paralleltree/Ched/blob/07d095e87330be4564c756b13322fa19f8674b3d/Ched/UI/Windows/ShortcutSettingsWindow.xaml.cs

ビューモデルとしてはShortcutManagerのUserShortcutKeySourceにアクセスできるオブジェクトを保持しつつ、ビュー側で選択されているコマンドをバインドするプロパティを持たせています。
キーが押されたときにSetShortcutKeyCommandが実行されるようXAMLのトリガーを記述し、ここでショートカット定義の反映を行います。

<i:Interaction.Triggers>
    <i:EventTrigger EventName="PreviewKeyDown">
        <i:InvokeCommandAction Command="{Binding SetShortcutKeyCommand}" PassEventArgsToCommand="True" />
    </i:EventTrigger>
</i:Interaction.Triggers>

コマンドの実装は以下のようになります。parentViewModelShortcutSettingsWindowViewModelへの参照です。
まずWinForms上でのキー表現に変換してから修飾キーのみのコンビネーションを弾き、ショートカット定義に矛盾が生じないよう、上書きする形で反映します。

public void Execute(object parameter)
{
    var e = (System.Windows.Input.KeyEventArgs)parameter;
    var key = ToWinFormsKey(e, System.Windows.Input.Keyboard.Modifiers);
    var keyCode = key & System.Windows.Forms.Keys.KeyCode;

    // Shift / Ctrl / Altのみは無効
    switch (keyCode)
    {
        case System.Windows.Forms.Keys.None:
        case System.Windows.Forms.Keys.ControlKey:
        case System.Windows.Forms.Keys.LControlKey:
        case System.Windows.Forms.Keys.RControlKey:
        case System.Windows.Forms.Keys.ShiftKey:
        case System.Windows.Forms.Keys.LShiftKey:
        case System.Windows.Forms.Keys.RShiftKey:
        case System.Windows.Forms.Keys.Alt:
        case System.Windows.Forms.Keys.Menu:
        case System.Windows.Forms.Keys.LMenu:
        case System.Windows.Forms.Keys.RMenu:
            return;
    }

    // 既に同じキーが別のコマンドへ割り当てられていれば解除
    if (parentViewModel.shortcutManagerHost.UserShortcutKeySource.ResolveCommand(key, out string _))
    {
        parentViewModel.shortcutManagerHost.UserShortcutKeySource.UnregisterShortcut(key);
        parentViewModel.RefreshView();
    }
    // 既に同じコマンドへ別のキーが割り当てられていれば解除
    if (parentViewModel.shortcutManagerHost.UserShortcutKeySource.ResolveShortcutKey(parentViewModel.SelectedShortcut.Command, out System.Windows.Forms.Keys registeredKey))
    {
        parentViewModel.shortcutManagerHost.UserShortcutKeySource.UnregisterShortcut(registeredKey);
    }
    parentViewModel.SelectedShortcut.Key = key;
    e.Handled = true;
}

WPFとWinFormsのキー変換

メインのフォームはWinFormsなのに対して、この設定画面はWPFなので微妙にキーの表現が異なります。
実際に作成したメソッドが以下ですが、システムキーが押されたときの実体のキーはKeyEventArgs.SystemKeyで取得することを知るのに時間がかかりました。
実体のキーを取得してから、修飾キーを反映したSystem.Windows.Forms.Keysを返します。

private System.Windows.Forms.Keys ToWinFormsKey(System.Windows.Input.KeyEventArgs e, System.Windows.Input.ModifierKeys modifiers)
{
    var actualKey = e.Key == System.Windows.Input.Key.System ? e.SystemKey : e.Key;
    var key = (System.Windows.Forms.Keys)System.Windows.Input.KeyInterop.VirtualKeyFromKey(actualKey);
    if (modifiers.HasFlag(System.Windows.Input.ModifierKeys.Control)) key |= System.Windows.Forms.Keys.Control;
    if (modifiers.HasFlag(System.Windows.Input.ModifierKeys.Shift)) key |= System.Windows.Forms.Keys.Shift;
    if (modifiers.HasFlag(System.Windows.Input.ModifierKeys.Alt)) key |= System.Windows.Forms.Keys.Alt;
    return key;
}

おわりに

駆け足気味でしたが、自前で実装したカスタムショートカット割り当てについてまとめました。

メニューバーの項目にショートカット設定を反映する部分は取り上げていませんが、ShortcutManagerのイベントを購読してキャプションを更新する形で実装が可能です。
この部分を含め、記事中で省略した細かい実装は以下より確認できます。
https://github.com/paralleltree/Ched/tree/develop/Ched/UI/Shortcuts

Discussion