🚶

keyhacでWindowWalkerもどき

に公開

https://github.com/betsegaw/windowwalker

2025年5月現在、PowerToysのランチャであるPowerToys Runプラグインとして組み込まれています。

ランチャ上で < に続けた入力から開いているウィンドウを絞り込んでアクティブ化する機能で、Alt+Tab連打から解放してくれる神ツールです。

今回はkeyhacでこのツールを再現してみようと思います。

完成形

コード全体(最低限の記述でもなかなかのボリューム)
import time
import shutil
import subprocess

import ckit
import pyauto
from keyhac import *


def configure(keymap):

    def delay(msec: int = 50) -> None:
        if 0 < msec:
            time.sleep(msec / 1000)

    class WindowActivator:
        def __init__(self, wnd: pyauto.Window) -> None:
            self._target = wnd

        def check(self) -> bool:
            return pyauto.Window.getForeground() == self._target

        def activate(self) -> bool:
            if self.check():
                return True

            if self._target.isMinimized():
                self._target.restore()
                delay()

            interval = 40
            trial = 20
            for i in range(trial):
                if (i + 1) % 5 == 0:
                    keymap.InputKeyCommand("Alt", "Alt")()
                try:
                    self._target.setForeground()
                    delay(interval)
                    if self.check():
                        self._target.setForeground(True)
                        return True
                except Exception as e:
                    print("Failed to activate window due to exception:", e)
                    return False
            print("Failed to activate window due to timeout.")
            return False

    def is_keyhac_console(wnd: pyauto.Window) -> bool:
        return wnd.getProcessName() == "keyhac.exe" and not wnd.getFirstChild()

    def fuzzy_window_switcher() -> None:
        if shutil.which("fzf.exe") is None:
            return

        ignore_list = [
            "explorer.exe",
            "MouseGestureL.exe",
            "TextInputHost.exe",
            "SystemSettings.exe",
            "ApplicationFrameHost.exe",
        ]

        def _fzf_wnd(job_item: ckit.JobItem) -> None:
            job_item.result = []
            job_item.found = None
            d = {}
            proc = subprocess.Popen(
                ["fzf.exe"],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                encoding="utf-8",
            )

            def _walk(wnd: pyauto.Window, _) -> bool:
                if not wnd:
                    return False
                if not wnd.isVisible():
                    return True
                if not wnd.isEnabled():
                    return True
                if is_keyhac_console(wnd):
                    return True
                if wnd.getProcessName() in ignore_list:
                    return True
                if not wnd.getText():
                    return True
                if popup := wnd.getLastActivePopup():
                    n = popup.getProcessName().replace(".exe", "")
                    if t := popup.getText():
                        n += "[{}]".format(t)
                    d[n] = popup
                    proc.stdin.write(n + "\n")
                return True

            try:
                pyauto.Window.enum(_walk, None)
                proc.stdin.close()
            except Exception as e:
                print(e)
                return

            result, err = proc.communicate()
            if proc.returncode != 0:
                if err:
                    print(err)
                return
            result = result.strip()
            if len(result) < 1:
                return
            job_item.found = d.get(result, None)

        def _finished(job_item: ckit.JobItem) -> None:
            if job_item.found:
                result = WindowActivator(job_item.found).activate()
                if not result:
                    keymap.InputKeyCommand("LWin-T")()

        job = ckit.JobItem(_fzf_wnd, _finished)
        ckit.JobQueue.defaultQueue().enqueue(job)

    # 変換キーを修飾キー `U1` として指定
    keymap.replaceKey("(28)", 236)
    keymap.defineModifier(236, "User1")
    keymap_global = keymap.defineWindowKeymap()
    keymap_global["U1-E"] = fuzzy_window_switcher

挙動は以下の通り。

  1. 変換キーを押しながらEを押すとfzfが起動する
  2. その時点でのプロセスをストリームとして読み込み、fuzzy-matchingで絞り込む
  3. 選択されたプロセスのウィンドウをアクティブ化する

解説

各種機能は configure 関数をカスタムして実装します(以下、コードはインデントを省略しているのでコピペ時には注意)。

fuzzy_window_switcher 内部

keyhacでfzfなどの外部コマンドを使用するにはジョブキューを使ってサブスレッドに追い出してやる必要があります。

ckit.JobItem() の第1引数がサブスレッド内で実行され、その後に第2引数の関数が実行されるそうです。
両者でデータをやり取りするには引数に渡される job_item オブジェクトにプロパティとしてぶら下げます。

job = ckit.JobItem(_fzf_wnd, _finished)
ckit.JobQueue.defaultQueue().enqueue(job)

_fzf_wnd でfzfを呼び出す

    def _fzf_wnd(job_item: ckit.JobItem) -> None:
        job_item.result = []
        job_item.found = None
        d = {}
        proc = subprocess.Popen(
            ["fzf.exe"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            encoding="utf-8",
        )

        def _walk(wnd: pyauto.Window, _) -> bool:
            if not wnd:
                return False
            if not wnd.isVisible():
                return True
            if not wnd.isEnabled():
                return True
            if is_keyhac_console(wnd):
                return True
            if wnd.getProcessName() in ignore_list:
                return True
            if not wnd.getText():
                return True
            if popup := wnd.getLastActivePopup():
                n = popup.getProcessName().replace(".exe", "")
                if t := popup.getText():
                    n += "[{}]".format(t)
                d[n] = popup
                proc.stdin.write(n + "\n")
            return True

        try:
            pyauto.Window.enum(_walk, None)
            proc.stdin.close()
        except Exception as e:
            print(e)
            return

        result, err = proc.communicate()
        if proc.returncode != 0:
            if err:
                print(err)
            return
        result = result.strip()
        if len(result) < 1:
            return
        job_item.found = d.get(result, None)

pyauto.Window.enum() にコールバック関数を渡すことでプロセスを総覧できます。

この処理に結構時間がかかるので、 subprocess.run() を使ってfzfにデータを渡そうとすると黒い窓が開くまで一瞬ラグが生じてしまいます。

そこで subprocess.Popen() を使い、見つかったウィンドウから順次ストリームとしてfzfに渡すという対処をしてみました。
時間はあまり変わっていないのですが、すぐに黒い窓が開くので主観的に速くなって少しストレスが軽減されます。

なお、コールバックの _walk が各ウィンドウを見ていくときに、keyhacのコンソール画面が開いているとハングすることがあるため、is_keyhac_console でスキップしています。

また、IMEやエクスプローラなどの不可視の(はずの)ウィンドウも検出されてしまいます。
そうした無視したいプロセスは ignore_list であらかじめ指定しておきます。

_finished で事後処理

def _finished(job_item: ckit.JobItem) -> None:
    if job_item.found:
        result = WindowActivator(job_item.found).activate()
        if not result:
            keymap.InputKeyCommand("LWin-T")()

_fzf_wnd で目的のウィンドウが選択されていれば、 job_item.foundpyauto.Windowオブジェクトが格納されています。

複数回リトライしつつウィンドウをアクティブ化し、それでも失敗したらWin+Tを仮想的に押すことでタスクバーにフォーカスが移ります。

ウィンドウのアクティブ化

class WindowActivator:
    def __init__(self, wnd: pyauto.Window) -> None:
        self._target = wnd

    def check(self) -> bool:
        return pyauto.Window.getForeground() == self._target

    def activate(self) -> bool:
        if self.check():
            return True

        if self._target.isMinimized():
            self._target.restore()
            delay()

        interval = 40
        trial = 20
        for i in range(trial):
            if (i + 1) % 5 == 0:
                keymap.InputKeyCommand("Alt", "Alt")()
            try:
                self._target.setForeground()
                delay(interval)
                if self.check():
                    self._target.setForeground(True)
                    return True
            except Exception as e:
                print("Failed to activate window due to exception:", e)
                return False
        print("Failed to activate window due to timeout.")
        return False

setForeground だけではタスクバーが点滅するだけでウィンドウのアクティブ化に失敗することがあります。その回避のためのワークアラウンドが以下。

  • 40msecごとに最大20回リトライする
  • リトライの5回に1回は仮想的にAltキーを2度押しする

後者はAutohotkeyのWinActivateを参考にしました。


全体のリポジトリはこちら。日々更新中。

https://github.com/AWtnb/keyhac

Discussion