keyhacで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
挙動は以下の通り。
- 変換キーを押しながらEを押すとfzfが起動する
- その時点でのプロセスをストリームとして読み込み、fuzzy-matchingで絞り込む
- 選択されたプロセスのウィンドウをアクティブ化する
解説
各種機能は 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.found
にpyauto.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を参考にしました。
全体のリポジトリはこちら。日々更新中。
Discussion