63
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

マウスジェスチャツールの作り方

Last updated at Posted at 2016-10-20

かざぐるマウスからの乗り換え先を探してたのですが、要求条件を満たすものがなかったのでフルスクラッチで書きました。現時点(バージョン2.2)で僕が使うには不満のないデキになったので、紹介がてら、忘れないうちに開発のメモやポイントなどを書き綴ろうと思います。

なお、C#で何かを書くのはこれが数回目で、かつ最新の文法書を通読したことがなく、逆引きでつまみ食い学習したため、C#的にはアレだったりするかもしれません。なにか変なところがあったら遠慮なくマサカリください。

作ったツールの紹介

名前はCreviceAppで、2016年5月の第2週から6月の終わりまでを使って書きました。その後はドッグフーディングして、バグを見つけるたびに潰しています。

必須要件

  • Windows 7 以降
  • .Net Framework 4.6 以上

特徴

信頼できるツールとして設計しました。取りこぼしなどがなく、高速で、イライラしません。
他のマウスジェスチャツールで標準的なものでも、僕が使わないであろう機能(GUIでの設定、ジェスチャ軌跡の描写機能など)はバッサリ切っています。
設定をC#スクリプト(csxファイル)として記述できるので、WindowsAPIを叩いたり、DLLをロードしたり、何でもできます。
ジェスチャとしてはストロークジェスチャ、ロッカージェスチャに対応します。
単純にボタンのクリックにアクションを割り当てたり、押下, 開放に直接フックを掛けることもできます。
オープンソース。MITライセンスです。
コア部分は2000行ほどとコンパクトなので、改造したりするのも簡単なのではと思います。

導入方法

zipファイルを適当な位置に解凍して、CreviceApp.exeを実行してください。
起動すると、自動的にデフォルトの設定ファイルが作られ、いくつかのジェスチャ定義が読み込まれます。デフォルトの設定ファイルには、ブラウザに対するジェスチャが一通り定義されています。

SnapCrab_新しい通知_2016-10-22_10-41-0_No-00.png

タスクトレイにアイコンが出るので、それをクリックするとウィンドウが開きます。
SnapCrab_NoName_2016-10-22_10-41-33_No-00.png

SnapCrab_CreviceApp_2016-10-21_5-48-59_No-00.png

「Open user directory」をクリックすると、エクスプローラーが%APPDATA%\Crevice\CreviceAppを開きます。

default.csxが設定ファイルなので、後はそれを自由に書き換えて、アプリを再起動するだけです。Enjoy!

設定DSLの文法

まずジェスチャを定義するには@whenから始めます。フォアグラウンドアプリがChromeなときだけ発動するジェスチャを定義していきます。

var Chrome = @when((ctx) =>
{
    return ctx.ForegroundWindow.ModuleName == "chrome.exe";
});

続いて、@on, @if, @doで一つのジェスチャが定義できます。

Chrome.
@on(RightButton).
@if(MoveDown, MoveRight).
@do((ctx) =>
{
    SendInput.Multiple().
    ExtendedKeyDown(VK_CONTROL).
    KeyDown(VK_W).
    KeyUp(VK_W).
    ExtendedKeyUp(VK_CONTROL).
    Send(); // Close tab
});

RightButtonからのストロークジェスチャ↓→を定義しました。ここで発動するアクションはタブを閉じるになります。

同様に@on, @if, @doを使って今度はRightButton + WheelDown, RightButton + WheelUpタブの切り替えを割り当てます。これらはロッカージェスチャです。

Chrome.
@on(RightButton).
@if(WheelDown).
@do((ctx) =>
{
    SendInput.Multiple().
    ExtendedKeyDown(VK_CONTROL).
    ExtendedKeyDown(VK_TAB).
    ExtendedKeyUp(VK_TAB).
    ExtendedKeyUp(VK_CONTROL).
    Send(); // Next tab
});
Chrome.
@on(RightButton).
@if(WheelUp).
@do((ctx) =>
{
    SendInput.Multiple().
    ExtendedKeyDown(VK_CONTROL).
    ExtendedKeyDown(VK_SHIFT).
    ExtendedKeyDown(VK_TAB).
    ExtendedKeyUp(VK_TAB).
    ExtendedKeyUp(VK_SHIFT).
    ExtendedKeyUp(VK_CONTROL).
    Send(); // Previous tab
});

さてChromeはとりあえず置いておいて、コンテキストなしでX1Buttonにアクションを割り当ててみます。この@whenは常にtrueを返すので、ジェスチャはいつでも発動します。

var Whenever = @when((ctx) =>
{
    return true;
});
Whenever.
@if(X1Button).
@do((ctx) =>
{
    SendInput.Multiple().
    ExtendedKeyDown(VK_MENU).
    ExtendedKeyDown(VK_TAB).
    ExtendedKeyUp(VK_TAB).
    ExtendedKeyUp(VK_MENU).
    Send(); // Sending Alt+Tab; this will work fine except on Window 8
});

割り当てられたアクションはAlt + Tabです。よく使いますよね。ここで@onが指定されていないことに注意してください。つまりX1Buttonに直接アクションを割り当てたことになります。

続いて、同様にコンテキストなしで、今度はX2ButtonのDown/Upにアクションを割り当てます。

Whenever.
@if(X2Button).
@before((ctx) =>
{
    SendInput.ExtendedKeyDown(VK_CONTROL); // Ctrl Down
}).
@after((ctx) =>
{
    SendInput.ExtendedKeyUp(VK_CONTROL); // Ctrl Up
});

これでX2ButtonCtrlキーとして使用することができます。

@before, @afterはちょっと微妙な表現ですが、両者の間に@doを挟むことができるので、@doの前と後ということです。この@before@afterが使えるのは、@ifLeftButton, MiddleButton, RightButton, X1Button, X2Buttonのいずれかを引数に取るときだけであることに注意してください。@ifWheelDown, WheelUp, WheelLeft, WheelRightを引数に取るときは@doのみが指定可能です。

状態を持つ設定の例

あるところに懐ゲーマニアがいました。彼は懐ゲーを愛し、毎日のようにプレイしていましたが、たったひとつだけ許せないことがありました。それはホイールコロコロでテキストを読み進められないゲームがあることでした。ホイールをクリックに変換してくれるようなツールもありますが、ゲームによっていちいちツールを設定し直すのが面倒なのでした。そこでCreviceAppです。

var Wheel2Click = false;

var Whenever = @when((ctx) =>
{
    return true;
});

Whenever.
@on(RightButton).
@if(LeftButton).
@do((ctx) =>
{
    Wheel2Click = !Wheel2Click;
    Tooltip(string.Format("Wheel2Click {0}", Wheel2Click ? "enabled" : "disabled"));
});

Whenever.
@if(WheelDown).
@do((ctx) =>
{
    if (Wheel2Click)
    {
        SendInput.LeftClick();
    }
    else
    {
        SendInput.WheelDown();
    }
});

RightButton + LeftButtonで機能のオンオフを切り替えることができます。ここで、設定中のグローバルな値であるWheel2Clickは、CreviceAppの起動から終了までの間を通じて保持されます。

おわりに

以上が簡単な説明になります。詳細な説明は適当な英語でプロジェクトページに書いています。@whenでの他のコンテキストの指定Configでのパラメータの設定WindowInfo, SendInput, Window, Tooltip, BaloonなどのAPIなど、ここでは割愛した内容を含んでいます。

最新のリリースは2.2.153です。よかったら使ってみてください。

rubyu/CreviceApp
Releases · rubyu/CreviceApp

マウスジェスチャツールの作り方

基本的な仕組み

マウスジェスチャツールはマウス入力にフックを掛けて、飛んでくるマウスメッセージを解釈し、必要であれば何らかのアクションを実行し、最後にマウスメッセージに対する返答「よし通れ!」「通さぬぞ!」のどちらかを返します。このとき通過を許可されたマウスメッセージは、他のフックがあれば同様にその審査も経て、最終的にはアプリケーションに到達します。通過を拒否されたマウスメッセージは破棄され、他のフックやアプリケーションに渡ることはありません。

[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, SystemCallback callback, IntPtr hInstance, int threadId);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UnhookWindowsHookEx(IntPtr hook);
[DllImport("user32.dll")]
private static extern IntPtr CallNextHookEx(IntPtr idHook, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll")]
public static extern IntPtr GetModuleHandle(string name);

private const int HC_ACTION = 0;
private const int WH_MOUSE_LL = 14;
private static readonly IntPtr LRESULTCancel = new IntPtr(1);

private IntPtr hHook = IntPtr.Zero;

public void SetHook()
{
    var hInstance = GetModuleHandle(Process.GetCurrentProcess().MainModule.ModuleName);
    hHook = SetWindowsHookEx(WH_MOUSE_LL, Callback, hInstance, 0);
}

public void Unhook()
{
    UnhookWindowsHookEx(hHook);
}

public IntPtr Callback(int nCode, IntPtr wParam, IntPtr lParam)
{
    if (nCode >= 0)
    {
        // 何か処理をする
        if (よし通れ!)
        {
            return CallNextHookEx(hHook, nCode, wParam, lParam);
        }
        if (通さぬぞ!)
        {
            return LRESULTCancel;
        }
    }
    return CallNextHookEx(hHook, nCode, wParam, lParam);
}

エラー処理などは省いていますが、非常に簡単ですね。あとはアプリケーションの起動時にSetHook()し、終了時にUnhook()すれば、マウスジェスチャツールの骨組みはできあがりです。

SetHook()した後にマウスメッセージが発生するたび、Callback()が呼ばれます。ここにキモとなるロジックを書き加えていくことになります。

注意: 後述しますが、WH_MOUSE_LLなどのグローバルフックを掛ける場合、SetWindowsHookEx()をコールしたスレッドがメッセージループを持っていなければなりません。

参考:
SetWindowsHookEx 関数
CallNextHookEx 関数
LowLevelMouseProc 関数

Tips

メッセージの送信

例えば、右クリックから開始されたジェスチャがタイムアウトでキャンセルとなった場合、このRightButtonDownをマウスジェスチャツールから送信する必要があります。いろいろな方法がありますが、mouse_eventには後述する問題があるため、SendInputを使うのが無難ではないかと思います。

SendInput
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);

[StructLayout(LayoutKind.Sequential)]
protected struct INPUT
{
    public int type;
    public MOUSEINPUT data;
}

[StructLayout(LayoutKind.Sequential)]
protected struct WHEELDELTA
{
    public int delta;
}

[StructLayout(LayoutKind.Sequential)]
protected struct XBUTTON
{
    public int type;
}

[StructLayout(LayoutKind.Explicit)]
protected struct MOUSEDATA
{
    [FieldOffset(0)]
    public WHEELDELTA asWheelDelta;
    [FieldOffset(0)]
    public XBUTTON asXButton;
}

[StructLayout(LayoutKind.Sequential)]
protected struct MOUSEINPUT
{
    public int dx;
    public int dy;
    public MOUSEDATA mouseData;
    public uint dwFlags;
    public uint time;
    public UIntPtr dwExtraInfo;
}

// https://msdn.microsoft.com/ja-jp/library/windows/desktop/ms646273(v=vs.85).aspx
[Flags]
private enum XButtonType
{
    XBUTTON1 = 0x01,
    XBUTTON2 = 0x02
}

// https://msdn.microsoft.com/ja-jp/library/windows/desktop/ms646270(v=vs.85).aspx
[Flags]
private enum InputType
{
    INPUT_MOUSE    = 0x0,
    INPUT_KEYBOARD = 0x1,
    INPUT_HARDWARE = 0x2
}

// https://msdn.microsoft.com/ja-jp/library/windows/desktop/ms646260(v=vs.85).aspx
[Flags]
private enum MouseEventType 
{
    MOUSEEVENTF_MOVE       = 0x0001,
    MOUSEEVENTF_LEFTDOWN   = 0x0002,
    MOUSEEVENTF_LEFTUP     = 0x0004,
    MOUSEEVENTF_RIGHTDOWN  = 0x0008,
    MOUSEEVENTF_RIGHTUP    = 0x0010,
    MOUSEEVENTF_MIDDLEDOWN = 0x0020,
    MOUSEEVENTF_MIDDLEUP   = 0x0040,
    MOUSEEVENTF_XDOWN      = 0x0080,
    MOUSEEVENTF_XUP        = 0x0100,
    MOUSEEVENTF_WHEEL      = 0x0800,
    MOUSEEVENTF_HWHEEL     = 0x1000,
    MOUSEEVENTF_ABSOLUTE   = 0x8000
}

private const int WHEEL_DELTA = 120;

protected void Send(INPUT input)
{
    var messages = new INPUT[1];
    messages[0] = input;
    SendInput((uint)messages.Length, messages, Marshal.SizeOf(messages[0]));
}

protected INPUT ToInput(MOUSEINPUT mouseInput)
{
    var input = new INPUT();
    input.type = (int)InputType.INPUT_MOUSE;
    input.data = mouseInput;
    return input;
}

private MOUSEINPUT GetMouseInput()
{
    var mouseInput = new MOUSEINPUT();
    mouseInput.time = 0;
    return mouseInput;
}

protected MOUSEINPUT MouseLeftDownEvent()
{
    var mouseInput = GetMouseInput();
    mouseInput.dwFlags = (int)MouseEventType.MOUSEEVENTF_LEFTDOWN;
    return mouseInput;
}

protected MOUSEINPUT MouseLeftUpEvent()
{
    var mouseInput = GetMouseInput();
    mouseInput.dwFlags = (int)MouseEventType.MOUSEEVENTF_LEFTUP;
    return mouseInput;
}

protected MOUSEINPUT MouseRightDownEvent()
{
    var mouseInput = GetMouseInput();
    mouseInput.dwFlags = (int)MouseEventType.MOUSEEVENTF_RIGHTDOWN;
    return mouseInput;
}

protected MOUSEINPUT MouseRightUpEvent()
{
    var mouseInput = GetMouseInput();
    mouseInput.dwFlags = (int)MouseEventType.MOUSEEVENTF_RIGHTUP;
    return mouseInput;
}
Send(ToInput(MouseRightDownEvent()));

実装例: CreviceApp/WinAPI.SendInput.cs at 2.0 · rubyu/CreviceApp

リンク先の実装例では、キーボードメッセージ、及び複数個のメッセージの送信にも対応しています。keybd_event, mouse_eventには複数個のメッセージを、他の入力に妨げられることなく連続したものとして送信する機能がないため、SendInputをオススメします。

注意: 後述しますが、SetWindowsHookEx()でグローバルフックをセットしたスレッドと同一のスレッドからマウスメッセージを送信すると、特定の環境で問題が発生します。このような場合は別スレッドからマウスメッセージを送信するようにしてください。

参考:
SendInput 関数
INPUT structure (Windows)
MOUSEINPUT structure (Windows)

その他のAPI

その他のAPIとして、定番どころではSendMessage, PostMessageなどがあります。マウスジェスチャツール本体では使用しなくても、ユーザーアクションから呼び出せると便利でしょう。

[DllImport("user32.dll")]
public static extern long SendMessage(IntPtr hWnd, uint Msg, uint wParam, uint lParam);

[DllImport("user32.dll", SetLastError = true)]
public extern static bool PostMessage(IntPtr hWnd, uint Msg, uint wParam, uint lParam);

ウィンドウハンドルが絡むので、CreviceAppではカプセル化して簡単に扱えるようにしました。ざっくりとしたコードは以下のようなものです。

public static class Window
{
    ...
    public static WindowInfo From(IntPtr hWnd) { ... }
    public static Point GetCursorPos() { ... }
    public static Point GetPhysicalCursorPos() { ... }
    public static WindowInfo GetForegroundWindow() { ... }
    public static WindowInfo GetPointedWindow() { ... }
    public static WindowInfo WindowFromPoint(Point point) { ... }
    public static WindowInfo FindWindow(string lpClassName, string lpWindowName) { ... }
    public static IEnumerable<WindowInfo> GetTopLevelWindows() { ... }
    public static IEnumerable<WindowInfo> GetThreadWindows(uint threadId) { ... }
}

public class WindowInfo
{
    ...
    public readonly IntPtr WindowHandle;
    public readonly int ThreadId;
    public readonly int ProcessId;
    public readonly IntPtr WindowId;
    public string Text;
    public readonly string ClassName;
    public readonly WindowInfo Parent;
    public readonly string ModulePath;
    public readonly string ModuleName;
    ...
    public bool BringWindowToTop() { ... }
    public long SendMessage(uint Msg, uint wParam, uint lParam) { ... }
    public bool PostMessage(uint Msg, uint wParam, uint lParam) { ... }
    public WindowInfo FindWindowEx(IntPtr hwndChildAfter, string lpszClass, string lpszWindow) { ... }
    public WindowInfo FindWindowEx(string lpszClass, string lpszWindow) { ... }
    public IEnumerable<WindowInfo> GetChildWindows() { ... }
    public IEnumerable<WindowInfo> GetPointedDescendantWindows(Point point, Window.WindowFromPointFlags flags) { ... }
    public IEnumerable<WindowInfo> GetPointedDescendantWindows(Point point) { ... }
}

public class ForegroundWindowInfo : WindowInfo
{
    public static new class NativeMethods
    {
        [DllImport("user32.dll")]
        public static extern IntPtr GetForegroundWindow();
    }

    public ForegroundWindowInfo() : base(NativeMethods.GetForegroundWindow()) { }
}

public class PointedWindowInfo : WindowInfo
{
    public static new class NativeMethods
    {
        [DllImport("user32.dll")]
        public static extern IntPtr WindowFromPhysicalPoint(Point point);
    }

    public PointedWindowInfo(Point point) : base(NativeMethods.WindowFromPhysicalPoint(point)) { }
}

実装例: CreviceApp/WinAPI.Window.cs at 2.0 · rubyu/CreviceApp

勝手TaskScheduler

ユーザーアクションだけを隔離して、かつ実行順序を保証するために単一のスレッドで実行したいようなシーンでは、勝手TaskSchedulerを書くのがよさそうでした。

// http://www.codeguru.com/csharp/article.php/c18931/Understanding-the-NET-Task-Parallel-Library-TaskScheduler.htm
public class SingleThreadScheduler : TaskScheduler, IDisposable
{
    private readonly BlockingCollection<Task> tasks = new BlockingCollection<Task>();
    private readonly Thread thread;

    public SingleThreadScheduler() : this(ThreadPriority.Normal) { }
        
    public SingleThreadScheduler(ThreadPriority priority)
    {
        this.thread = new Thread(new ThreadStart(Main));
        this.thread.Priority = priority;
        this.thread.Start();
    }

    private void Main()
    {
        Debug.Print("SingleThreadScheduler was started; Thread ID: 0x{0:X}", Thread.CurrentThread.ManagedThreadId);
        foreach (var t in tasks.GetConsumingEnumerable())
        {
            TryExecuteTask(t);
        }
    }

    protected override IEnumerable<Task> GetScheduledTasks()
    {
        return tasks.ToArray();
    }

    protected override void QueueTask(Task task)
    {
        tasks.Add(task);
    }

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        return false;
    }

    public void Dispose()
    {
        GC.SuppressFinalize(this);
        tasks.CompleteAdding();
    }

    ~SingleThreadScheduler()
    {
        Dispose();
    }
}
ツールチップを任意の位置に表示する

バルーンメッセージやトーストで通知するのは大仰だというときに、任意の場所にツールチップを表示できるとよいのですが、スマートにやる方法にはたどり着けませんでした。とりあえず以下のように力技でどうにかしました。

public class TooltipNotifier : IDisposable
{
    private const int Absolute = 0x0002;
    
    private IWin32Window win;
    private MethodInfo SetTool;
    private MethodInfo SetTrackPosition;
    private MethodInfo StartTimer;
    
    private ToolTip tooltip = new ToolTip();
    
    public TooltipNotifier(IWin32Window win)
    {
        this.win = win;
        this.SetTool = tooltip.GetType().GetMethod("SetTool", BindingFlags.Instance | BindingFlags.NonPublic);
        this.SetTrackPosition = tooltip.GetType().GetMethod("SetTrackPosition", BindingFlags.Instance | BindingFlags.NonPublic);
        this.StartTimer = tooltip.GetType().GetMethod("StartTimer", BindingFlags.Instance | BindingFlags.NonPublic);
    }

    public void Show(string text, Point point, int duration)
    {
        SetTrackPosition.Invoke(tooltip, new object[] { point.X, point.Y });
        SetTool.Invoke(tooltip, new object[] { win, text, Absolute, point });
        StartTimer.Invoke(tooltip, new object[] { win, duration });
    }

    public void Dispose()
    {
        GC.SuppressFinalize(this);
        tooltip.Dispose();
    }

    ~TooltipNotifier()
    {
        Dispose();
    }
}

なお、座標についてはある程度、Windows側でよしなにやってくれます。例えば1920x1080のディスプレイ環境でTT.Show("どや!", 1920, 1080);としても、ツールチップはタスクバー上には表示されず、タスクバーを除いたエリアの右下隅にフィットするように表示されます。

複雑さとの戦い

コアロジックのFSM化

マウスジェスチャには様々な種類があります。ボタンを押したままマウスを動かすストロークジェスチャ、あるボタンを押したまま他のボタンを押すロッカージェスチャ、あるいは単にあるボタンを押すという動作もジェスチャとして扱いたいかもしれません。それらの全てをフラグで管理していたのでは大変ですし、とても保守できる気がしません。何かスマートな解決策が必要です。

そこで同時に押すボタンは2つまでとして、

  1. 初期状態: 何もボタンは押されていない
  2. 1つめのボタンが押されている
  3. 2つめのボタンが押されている

という3つの状態を考えれば、これらは明らかに有限で、その遷移も明白なように見えます。有限状態マシンで実装するのがよさそうです。この2番目の状態を、「ジェスチャを開始したボタンを復元できるかどうか」でさらに2つに分けて、次のような合計4つの状態を考えました。

  1. 初期状態: 何もボタンは押されていない
  2. 1つめのボタンが押されている (ジェスチャ開始ボタンは復元できる)
  3. 1つめのボタンが押されている (ジェスチャ開始ボタンは復元できない)
  4. 2つめのボタンが押されている

実装としては次のようなものです。なお、シングルアクションボタンWheelUp, WheelDown, WheelLeft, WheelRightのいずれか、ダブルアクションボタンLeft, Middle, Right, X1, X2のいずれかです。後者にはボタンの押下と開放に対応した、セットリリースの2種があるとします。

var res = State.Input(evnt, point);   
if (State.GetType() != res.NextState.GetType())
{
    Debug.Print("The state of GestureMachine was changed: {0} -> {1}", State.GetType().Name, res.NextState.GetType().Name);
}
State = res.NextState;
return res.Event.IsConsumed;
public interface IState
{
    Result Input(Def.Event.IEvent evnt, Point point);
}
public abstract class State : IState
{
    public virtual Result Input(Def.Event.IEvent evnt, Point point)
    {
        return Result.EventIsRemained(nextState: this);
    }
}
public class State0 : State
{
    public override Result Input(Def.Event.IEvent evnt, Point point)
    {
        if (シングルアクションボタン)
        {
            if (ジェスチャが存在する)
            {
                // ユーザーアクションの実行
                return Result.EventIsConsumed(nextState: this);
            }
        }
        else if (ダブルアクションボタンのセット)
        {
            if (ジェスチャが存在する)
            {
                return Result.EventIsConsumed(nextState: new State1(...));
            }
        }
        return base.Input(evnt, point);
    }
}
public class State1 : State
{
    public override Result Input(Def.Event.IEvent evnt, Point point)
    {
        // ストロークの監視クラスにpointを渡す
        
        if (シングルアクションボタン)
        {
            if (ジェスチャが存在する)
            {
                // ユーザーアクションの実行
                return Result.EventIsConsumed(nextState: new State2(...));
            }
        }
        else if (ダブルアクションボタンのセット)
        {
            if (ジェスチャが存在する)
            {
                return Result.EventIsConsumed(nextState: new State3(...));
            }
        }
        else if (ダブルアクションボタンのリリース)
        {
            if (入力が1番目に押されたボタンのペアなら)
            {
                if (ストロークの入力がある)
                {
                    if (ジェスチャが存在する)
                    {
                        // ユーザーアクションの実行
                    }
                }
                else
                {
                    if (ジェスチャが存在する)
                    {
                        // ユーザーアクションの実行
                    }
                    else
                    {
                        // 1番目に押されたボタンのクリックを復元する
                    }
                }
                return Result.EventIsConsumed(nextState: S0);
            }
        }
        return base.Input(evnt, point);
    }
}
public class State2 : State
{
    public override Result Input(Def.Event.IEvent evnt, Point point)
    {
        // ストロークの監視クラスにpointを渡す
        
        if (シングルアクションボタン)
        {
            if (ジェスチャが存在する)
            {
                // ユーザーアクションの実行
                return Result.EventIsConsumed(nextState: new State2(...));
            }
        }
        else if (ダブルアクションボタンのセット)
        {
            if (ジェスチャが存在する)
            {
                return Result.EventIsConsumed(nextState: new State3(...));
            }
        }
        else if (ダブルアクションボタンのリリース)
        {
            if (入力が1番目に押されたボタンのペアなら)
            {
                if (ストロークの入力がある)
                {
                    if (ジェスチャが存在する)
                    {
                        // ユーザーアクションの実行
                    }
                }
                return Result.EventIsConsumed(nextState: S0);
            }
        }
        return base.Input(evnt, point);
    }
}
public class State3 : State
{
    public override Result Input(Def.Event.IEvent evnt, Point point)
    {
        if (ダブルアクションボタンのリリース)
        {
            if (入力が2番目に押されたボタンのペアなら)
            {
                // ユーザーアクションの実行
                return Result.EventIsConsumed(nextState: S2);
            }
            else if (入力が1番目に押されたボタンのペアなら)
            {
                // 2番目に押されたボタンを「次回は無視する」リストに入れる
                return Result.EventIsConsumed(nextState: S0);
            }
        }
        return base.Input(evnt, point);
    }
}

どうでしょうか、状態ごとに分けて考えると、それほど難しくないと思います。全ての遷移を書き出して、それに対応するコードとテストを書いていくと安心です。

遷移の例: CreviceApp/Core.FSM.Transition.cs at 2.0 · rubyu/CreviceApp

GUIでの設定を諦める(Roslynのススメ)

多岐にわたるマウスジェスチャツールの用途を網羅するような、万能な設定をGUIで作り込むには途方もない時間がかかりそうです。CreviceAppの場合、1人しか使う予定がないマウスジェスチャツールのために膨大な人月をつぎ込むのは明らかに誤りでした。ここはスッパリとGUIを切り捨てて、テキストベースで設定を行えるようにしましょう。もちろん、テキストベースといっても、独自のスクリプトを作り込んだりするのも明らかに時間の無駄です。ちょうどMicrosoft Roslynの安定版がリリースされた頃だったので、これをマウスジェスチャツールに組み込みました。

private IEnumerable<Core.GestureDefinition> EvaluateUserScriptAsync(Core.UserScriptExecutionContext ctx)
{
    var script = CSharpScript.Create(
        GetDefaultUserScript(),
        ScriptOptions.Default
            .WithSourceResolver(ScriptSourceResolver.Default.WithBaseDirectory(UserDirectory))
            .WithMetadataResolver(ScriptMetadataResolver.Default.WithBaseDirectory(UserDirectory))
            .WithReferences("microlib")                   // microlib.dll
            .WithReferences("System")                     // System.dll
            .WithReferences("System.Core")                // System.Core.dll
            .WithReferences("Microsoft.CSharp")           // Microsoft.CSharp.dll
            .WithReferences(Assembly.GetEntryAssembly()), // CreviceApp.exe
        globalsType: typeof(Core.UserScriptExecutionContext));
    script.Compile();
    script.RunAsync(ctx).Wait();
    return ctx.GetGestureDefinition();
}

protected void InitializeGestureMachine()
{
    var ctx = new Core.UserScriptExecutionContext(Global);
    var gestureDef = EvaluateUserScriptAsync(ctx);
    this.GestureMachine = new Core.FSM.GestureMachine(Global.UserConfig, gestureDef);
}

たったこれだけのコードで、csxファイルをコンパイルして実行できます。後は設定を表現するDSLを定義して、コードに書き下していけばいいだけです。

WHEN                  ::= @when(WHEN_FUNC)           ( ON | IF_A | IF_B )

ON                    ::= @on(DOUBLE_TRIGGER_BUTTON) ( IF_A | IF_B | IF_C )

IF_A                  ::= @if(DOUBLE_TRIGGER_BUTTON) [ BEFORE ] DO [ AFTER ]
          
IF_B                  ::= @if(SINGLE_TRIGGER_BUTTON)   DO

IF_C                  ::= @if(MOVE *)                  DO

BEFORE                ::= @before(BEFORE_FUNC)

DO                    ::= @do(DO_FUNC) 

AFTER                 ::= @after(AFTER_FUNC)

DOUBLE_TRIGGER_BUTTON ::= L | M | R | X1 | X2

SINGLE_TRIGGER_BUTTON ::= W_UP | W_DOWN | W_LEFT | W_RIGHT

MOVE                  ::= MOVE_UP | MOVE_DOWN | MOVE_LEFT | MOVE_RIGHT

WHEN_FUNC             ::= delegate bool

BEFORE_FUNC           ::= delegate void

DO_FUNC               ::= delegate void

AFTER_FUNC            ::= delegate void
public static class Def
{
    public delegate bool WhenFunc(Core.UserActionExecutionContext ctx);
    public delegate void BeforeFunc(Core.UserActionExecutionContext ctx);
    public delegate void DoFunc(Core.UserActionExecutionContext ctx);
    public delegate void AfterFunc(Core.UserActionExecutionContext ctx);

    public interface Button { }
    public interface AcceptableInOnClause : Button { }
    public interface AcceptableInIfButtonClause : Button { }
    public interface AcceptableInIfSingleTriggerButtonClause : AcceptableInIfButtonClause { }
    public interface AcceptableInIfDoubleTriggerButtonClause : AcceptableInIfButtonClause { }

    public class LeftButton   : AcceptableInOnClause, AcceptableInIfDoubleTriggerButtonClause { }
    public class MiddleButton : AcceptableInOnClause, AcceptableInIfDoubleTriggerButtonClause { }
    public class RightButton  : AcceptableInOnClause, AcceptableInIfDoubleTriggerButtonClause { }
    public class WheelUp      :                       AcceptableInIfSingleTriggerButtonClause { }
    public class WheelDown    :                       AcceptableInIfSingleTriggerButtonClause { }
    public class WheelLeft    :                       AcceptableInIfSingleTriggerButtonClause { }
    public class WheelRight   :                       AcceptableInIfSingleTriggerButtonClause { }
    public class X1Button     : AcceptableInOnClause, AcceptableInIfDoubleTriggerButtonClause { }
    public class X2Button     : AcceptableInOnClause, AcceptableInIfDoubleTriggerButtonClause { }
        
    public interface Move { }
    public interface AcceptableInIfStrokeClause : Move { }
    public class MoveUp    : AcceptableInIfStrokeClause { }
    public class MoveDown  : AcceptableInIfStrokeClause { }
    public class MoveLeft  : AcceptableInIfStrokeClause { }
    public class MoveRight : AcceptableInIfStrokeClause { }
        
    public class ConstantSingleton
    {
        private static ConstantSingleton singleton = new ConstantSingleton();

        public readonly LeftButton   LeftButton      = new LeftButton();
        public readonly MiddleButton MiddleButton    = new MiddleButton();
        public readonly RightButton  RightButton     = new RightButton();
        public readonly WheelDown    WheelDown       = new WheelDown();
        public readonly WheelUp      WheelUp         = new WheelUp();
        public readonly WheelLeft    WheelLeft       = new WheelLeft();
        public readonly WheelRight   WheelRight      = new WheelRight();
        public readonly X1Button     X1Button        = new X1Button();
        public readonly X2Button     X2Button        = new X2Button();

        public readonly MoveUp    MoveUp    = new MoveUp();
        public readonly MoveDown  MoveDown  = new MoveDown();
        public readonly MoveLeft  MoveLeft  = new MoveLeft();
        public readonly MoveRight MoveRight = new MoveRight();

        public static ConstantSingleton GetInstance()
        {
            return singleton;
        }
    }

    public static ConstantSingleton Constant
    {
        get { return ConstantSingleton.GetInstance(); }
    }
}
public class Root
{
    public readonly List<WhenElement.Value> whenElements = new List<WhenElement.Value>();

    public WhenElement @when(Def.WhenFunc func)
    {
        return new WhenElement(whenElements, func);
    }
}
public class WhenElement
{
    public class Value
    {
        public readonly List<IfSingleTriggerButtonElement.Value> ifSingleTriggerButtonElements = new List<IfSingleTriggerButtonElement.Value>();
        public readonly List<IfDoubleTriggerButtonElement.Value> ifDoubleTriggerButtonElements = new List<IfDoubleTriggerButtonElement.Value>();
        public readonly List<OnElement.Value> onElements = new List<OnElement.Value>();
        public readonly Def.WhenFunc func;

        public Value(Def.WhenFunc func)
        {
            this.func = func;
        }
    }
    
    private readonly Value value;

    public WhenElement(List<Value> parent, Def.WhenFunc func)
    {
        this.value = new Value(func);
        parent.Add(this.value);
    }

    public OnElement @on(Def.AcceptableInOnClause button)
    {
        return new OnElement(value.onElements, button);
    }

    public IfSingleTriggerButtonElement @if(Def.AcceptableInIfSingleTriggerButtonClause button)
    {
        return new IfSingleTriggerButtonElement(value.ifSingleTriggerButtonElements, button);
    }

    public IfDoubleTriggerButtonElement @if(Def.AcceptableInIfDoubleTriggerButtonClause button)
    {
        return new IfDoubleTriggerButtonElement(value.ifDoubleTriggerButtonElements, button);
    }
}
public class OnElement
{
    public class Value
    {
        public readonly List<IfSingleTriggerButtonElement.Value> ifSingleTriggerButtonElements = new List<IfSingleTriggerButtonElement.Value>();
        public readonly List<IfDoubleTriggerButtonElement.Value> ifDoubleTriggerButtonElements = new List<IfDoubleTriggerButtonElement.Value>();
        public readonly List<IfStrokeElement.Value> ifStrokeElements = new List<IfStrokeElement.Value>();
        public readonly Def.AcceptableInOnClause button;

        public Value(Def.AcceptableInOnClause button)
        {
            this.button = button;
        }
    }
    
    private readonly Value value;

    public OnElement(List<Value> parent, Def.AcceptableInOnClause button)
    {
        this.value = new Value(button);
        parent.Add(this.value);
    }

    public IfSingleTriggerButtonElement @if(Def.AcceptableInIfSingleTriggerButtonClause button)
    {
        return new IfSingleTriggerButtonElement(value.ifSingleTriggerButtonElements, button);
    }

    public IfDoubleTriggerButtonElement @if(Def.AcceptableInIfDoubleTriggerButtonClause button)
    {
        return new IfDoubleTriggerButtonElement(value.ifDoubleTriggerButtonElements, button);
    }

    public IfStrokeElement @if(params Def.AcceptableInIfStrokeClause[] moves)
    {
        return new IfStrokeElement(value.ifStrokeElements, moves);
    }
}
public class IfStrokeElement
{
    public class Value
    {
        public readonly List<SingleTriggerDoElement.Value> doElements = new List<SingleTriggerDoElement.Value>();

        public readonly IEnumerable<Def.AcceptableInIfStrokeClause> moves;

        public Value(IEnumerable<Def.AcceptableInIfStrokeClause> moves)
        {
            this.moves = moves;
        }
    }
    
    private readonly Value value;

    public IfStrokeElement(List<Value> parent, params Def.AcceptableInIfStrokeClause[] moves)
    {
        this.value = new Value(moves);
        parent.Add(this.value);
    }

    public SingleTriggerDoElement @do(Def.DoFunc func)
    {
        return new SingleTriggerDoElement(value.doElements, func);
    }
}
public class IfSingleTriggerButtonElement
{
    public class Value
    {
        public readonly List<SingleTriggerDoElement.Value> doElements = new List<SingleTriggerDoElement.Value>();

        public readonly Def.AcceptableInIfSingleTriggerButtonClause button;

        public Value(Def.AcceptableInIfSingleTriggerButtonClause button)
        {
            this.button = button;
        }
    }
    
    private readonly Value value;

    public IfSingleTriggerButtonElement(List<Value> parent, Def.AcceptableInIfSingleTriggerButtonClause button)
    {
        this.value = new Value(button);
        parent.Add(this.value);
    }

    public SingleTriggerDoElement @do(Def.DoFunc func)
    {
        return new SingleTriggerDoElement(value.doElements, func);
    }
}
public class SingleTriggerDoElement
{
    public class Value
    {
        public readonly Def.DoFunc func;

        public Value(Def.DoFunc func)
        {
            this.func = func;
        }
    }

    public SingleTriggerDoElement(List<Value> parent, Def.DoFunc func)
    {
        parent.Add(new Value(func));
    }
}
public class IfDoubleTriggerButtonElement
{
    public class Value
    {
        public readonly List<DoubleTriggerBeforeElement.Value> beforeElements = new List<DoubleTriggerBeforeElement.Value>();
        public readonly List<DoubleTriggerDoElement.Value> doElements = new List<DoubleTriggerDoElement.Value>();
        public readonly List<DoubleTriggerAfterElement.Value> afterElements = new List<DoubleTriggerAfterElement.Value>();

        public readonly Def.AcceptableInIfDoubleTriggerButtonClause button;

        public Value(Def.AcceptableInIfDoubleTriggerButtonClause button)
        {
            this.button = button;
        }
    }
    
    private readonly Value value;

    public IfDoubleTriggerButtonElement(List<Value> parent, Def.AcceptableInIfDoubleTriggerButtonClause button)
    {
        this.value = new Value(button);
        parent.Add(this.value);
    }
    
    public DoubleTriggerBeforeElement @before(Def.BeforeFunc func)
    {
        return new DoubleTriggerBeforeElement(value.beforeElements, value.doElements, value.afterElements, func);
    }

    public DoubleTriggerDoElement @do(Def.DoFunc func)
    {
        return new DoubleTriggerDoElement(value.doElements, value.afterElements, func);
    }

    public DoubleTriggerAfterElement @after(Def.AfterFunc func)
    {
        return new DoubleTriggerAfterElement(value.afterElements, func);
    }
}
public class DoubleTriggerBeforeElement
{
    public class Value
    {
        public readonly Def.BeforeFunc func;

        public Value(Def.BeforeFunc func)
        {
            this.func = func;
        }
    }

    private readonly List<DoubleTriggerDoElement.Value> doParent;
    private readonly List<DoubleTriggerAfterElement.Value> afterParent;

    public DoubleTriggerBeforeElement(
        List<Value> parentA, 
        List<DoubleTriggerDoElement.Value> parentB,
        List<DoubleTriggerAfterElement.Value> parentC,
        Def.BeforeFunc func)
    {
        parentA.Add(new Value(func));
        doParent = parentB;
        afterParent = parentC;
    }

    public DoubleTriggerDoElement @do(Def.DoFunc func)
    {
        return new DoubleTriggerDoElement(doParent, afterParent, func);
    }

    public DoubleTriggerAfterElement @after(Def.AfterFunc func)
    {
        return new DoubleTriggerAfterElement(afterParent, func);
    }
}
public class DoubleTriggerDoElement
{
    public class Value
    {
        public readonly Def.DoFunc func;

        public Value(Def.DoFunc func)
        {
            this.func = func;
        }
    }

    private readonly List<DoubleTriggerAfterElement.Value> afterParent;

    public DoubleTriggerDoElement(
        List<Value> parentA, 
        List<DoubleTriggerAfterElement.Value> parentB, 
        Def.DoFunc func)
    {
        parentA.Add(new Value(func));
        afterParent = parentB;
    }

    public DoubleTriggerAfterElement @after(Def.AfterFunc func)
    {
        return new DoubleTriggerAfterElement(afterParent, func);
    }
}
public class DoubleTriggerAfterElement
{
    public class Value
    {
        public readonly Def.AfterFunc func;

        public Value(Def.AfterFunc func)
        {
            this.func = func;
        }
    }

    public DoubleTriggerAfterElement(List<Value> parent, Def.AfterFunc func)
    {
        parent.Add(new Value(func));
    }
}

あとはこの@whenから始まるDSLで記述された設定を、扱いやすいようにパースしていけばいいでしょう。

パーサの実装例: CreviceApp/Core.DSLTreeParser.cs at 2.0 · rubyu/CreviceApp

まとめ

ここまで簡単にマウスジェスチャツールの作り方を解説してきました。要するに、

  • フックを掛ける
  • コールバック関数にマウスメッセージが渡される
  • コールバック関数内でジェスチャに関する処理を行う
  • マウスメッセージを通すか通さないかを結果として返す

これだけです。ここまで読んでくれたあなたなら、きっとマウスジェスチャツールを実装できます。

そしていざ、マウスジェスチャツールを実装するときに注意してほしいことがあります。それはレイテンシです。与えられたマウスメッセージは可能な限り迅速に処理しなければなりません。コールバック関数内でマウスメッセージを処理している間の時間が、ラグとしてユーザーのエクスペリエンスに影響を与えます。また、後述しますが、あまりにもラグが酷いと、フックがシステムによって外されてしまうこともあり得ます。低レイテンシを心がけてください。
CreviceAppが行っている低レイテンシのための対策としては、

  • ユーザーアクションは別スレッドで行う
  • カーソルの移動メッセージはある程度処理を間引く
  • カーソルの移動メッセージからジェスチャのストロークへの変換は別スレッドで行う

などがあります。

トラブルシューティング

うまくフックがかからない (1)

SetWindowsHookEx()をコールしたスレッドはメッセージループを持っていますか?

c++ - Why must SetWindowsHookEx be used with a windows message queue - Stack Overflow

うまくフックがかからない (2)

もしかしてWH_MOUSEでフックを掛けようとしていませんか? C#でやるにはかなり茨の道だと思われますので、必要がなければWH_MOUSE_LLを使ったほうがいいような気がします。

うまくフックがかからない (3)

C#でWin32 API(グローバルフック等)を使用してデバッグすると「指定されたモジュールが見つかりません」と表示されたとき。 - Code for final

「Visual Studio ホスティング プロセスを有効にする」チェックをはずすと普通に動いた。

フックは動作するが、しばらくするとエラーで終了してしまう

恐らくCallback関数がGCに回収されてしまうことが原因です。前述のコードに以下の修正を加えてください。具体的には、delegateとして変数に保持していれば大丈夫です。

private delegate IntPtr MouseCallback(int nCode, IntPtr wParam, IntPtr lParam);
private readonly MouseCallback mouseCallback;

public コンストラクタ()
{
    this.mouseCallback = Callback;
}

public void SetHook()
{
    var hInstance = GetModuleHandle(Process.GetCurrentProcess().MainModule.ModuleName);
    hHook = SetWindowsHookEx(WH_MOUSE_LL, mouseCallback, hInstance, 0);
}
フックは動作するが、勝手に無効になることがある

もしかして、ロジックの実行に時間がかかりすぎたりしていませんか?

Global hooks getting lost on Windows 7 – Decrypt my World

マウスメッセージがフックから漏れる

マウス入力をフックして、マウスメッセージを解釈し、何らかのアクションを実行する。…あれ? 例えば右クリックから開始されたジェスチャがキャンセルされたとき、右クリックをマウスジェスチャツールが復元するとします。マウスジェスチャツールが送信したマウスメッセージはフックを経由して、またマウスジェスチャツールに戻ってきてしまいます…。

この問題に対する簡単な対処の仕方は次のようなものです。

// 既にフックが実行されているとして
Unhook();
RestoreClickEvent();
SetHook();

しかしこの実装には問題があります。マウスジェスチャツールが復元したマウスメッセージは期待通りにフックを通過しますが、フックを一時的に解除しているため、その間にマウスから発生したメッセージもフックを通過してしまいます。マウスジェスチャツールによくある漏れというのがこれです。この実装では結果的にユーザーのマウス入力を意図通りに解釈できず、ツールの信頼性は損なわれます。

よりよい対処は、マウスジェスチャツールが発行したマウスメッセージには署名を埋め込むというものです。

var mouseInput = new MOUSEINPUT();
// Set the CreviceApp signature to the mouse event.
mouseInput.dwExtraInfo = MOUSEEVENTF_CREVICE_APP;

例えばペンデバイスからの入力かどうかは、dwExtraInfo0xFFFFFF00でマスクした後に0xFF515700と比較することで確認できます。
参考: System Events and Mouse Messages (Windows)

署名を埋め込むことにすれば、あとはマウスメッセージの署名を比較して、フィルタリングをするだけです。

public bool fromCreviceApp
{
    get { return (uint)dwExtraInfo == MOUSEEVENTF_CREVICE_APP; }
}
public WindowsHook.Result MouseProc(LowLevelMouseHook.Event evnt, LowLevelMouseHook.MSLLHOOKSTRUCT data)
{
    if (data.fromCreviceApp)
    {
        return WindowsHook.Result.Transfer;
    }
    ...
}
特定の条件下でフリーズする

マウス入力にフックを掛けつつ、同時にマウスメッセージを送信すると、特定の環境で問題が発生することがあります。具体的にはWindows10環境で、ユニバーサルアプリが動いてるときのみ、何秒間か、アプリケーションがフリーズするというものです。調べると、StrokesPlusで似たような現象が過去に生じていたようです。

StrokesPlus Forum - Free Mouse Gesture Utility for Windows XP/Vista/7/8

この問題は別スレッドからマウスメッセージを送信することで解決します。

特定の条件下でテストが失敗する

その条件というのはWindows10でユニバーサルアプリが動作しているとき…。はい、お察しの通り前述の問題です。同様の対策をとるか、あるいはWindows10のユニバーサルアプリを全部終了させてからテストを実行してください。

こんな通知ですら、この問題が発生するんです…。つらい。
SnapCrab_新しい通知_2016-10-21_20-53-38_No-00.png

高解像度環境で誤ったカーソルの位置にスケーリングされる

Windowsは、ある程度自動的に、アプリケーションの表示をディスプレイのDPIに合わせてスケーリングしてくれます。ただし、アプリケーションの認識するカーソル位置までスケーリングされてしまうと、マウスジェスチャツールの動作は正常なものではなくなってしまいます。自動的にディスプレイのDPIにアプリケーションの表示を追随させつつ、正しいカーソル位置を取得するには…という問題は以前は難しいものだったのですが、Windows 10 Creators Update(バージョン1703、ビルド15063)から新しくPerMonitorV2が追加され、多くのアプリケーションで簡単に解決できるようになりました。

WindowsのHigh DPI及びPer-Monitor DPIに対応するにはアプリケーションのマニュフェストに以下のような記述が必要になります。

<assembly xmlns="urn:schemas-microsoft-com:asm.v1"
    manifestVersion="1.0"
    xmlns:asmv3="urn:schemas-microsoft-com:asm.v3" >
  <asmv3:application>
    <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
      <dpiAware>
        true/PM
      </dpiAware>
      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">
        PerMonitorV2, PerMonitor
      </dpiAwareness>
    </asmv3:windowsSettings>
  </asmv3:application>
</assembly>

なお、上記の設定で、カーソル位置のスケーリングは無効になり、正しいものを取得できるようになりますが、2種のPer-Monitor API(PerMonitor, PerMonitorV2)が追加された前後、つまりWindows 8.1以前、Windows 10 CU以前、Windows 10 CU以降でそれぞれアプリケーションの表示スケーリングは異なります。そう、異なるのです…。CreviceAppではHigh DPI環境の人はWindows 10 CU以降を推奨しますよーということでお茶を濁します。

Chromium Edgeで右クリックが効かなくなる

dwExtraInfoに0xFFFFFF00のような雑なパターンを与えるとその他のブラウザでは正常に動作するが、Chromium Edgeだけで右クリックが効かなくなる不具合が生じます。

63
74
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
63
74

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?