keyhac 自分用設定まとめ


無人島に1つだけ持っていけるとしたら、私は [keyhac](https://sites.google.com/site/craftware/keyhac-ja) を持っていきます。

## 基本設定

exe と同ディレクトリに置かれる `config.py` 内の `configure()`  関数を編集していきます。

```config.py
import sys
import os
import datetime
import re # 追加
import time # 追加
import urllib.parse # 追加

import pyauto
from keyhac import *

def configure(keymap):

    # scoop で入手した vscode で編集（なければメモ帳）
    EDITOR_PATH = r"C:\Users\{}\scoop\apps\vscode\current\Code.exe".format(os.environ.get("USERNAME"))
    if not os.path.exists(EDITOR_PATH):
        EDITOR_PATH = "notepad.exe"
    keymap.editor = EDITOR_PATH

    # theme
    keymap.setFont("HackGen Console", 16)
    keymap.setTheme("black")

    # 変換／無変換キーを user modifier とする
    keymap.replaceKey("(29)", 235)      # 無変換（29）を 235 とする
    keymap.defineModifier(235, "User0") # 無変換を U0 修飾キーとして使う
    keymap.replaceKey("(28)", 236)      # 変換（28）を 236 とする
    keymap.defineModifier(236, "User1") # 変換を U1 修飾キーとして使う

    # クリップボード履歴有効化
    keymap.clipboard_history.enableHook(True)

    # 履歴の最大サイズ
    keymap.clipboard_history.maxnum = 200
    keymap.clipboard_history.quota = 10*1024*1024

    # 履歴から Ctrl+Enter で引用貼り付けするときの記号
    keymap.quote_mark = "> "

```

フォントは [白源（HackGen）](https://github.com/yuru7/HackGen) が好きです。

以下、カスタマイズ内容はすべて `configure()` 内に書いていきます。変数名・関数名にイマイチ規則がないのは目をつぶってください……。

### カーソル移動

```config.py
    # 常に有効になるキーリマップ
    keymap_global = keymap.defineWindowKeymap()
    
    for modifier in ("", "S-", "C-", "A-", "C-S-", "C-A-", "S-A-", "C-A-S-"):
        # 上下左右
        keymap_global[modifier + "U0-H"] = modifier + "Left"
        keymap_global[modifier + "U0-J"] = modifier + "Down"
        keymap_global[modifier + "U0-K"] = modifier + "Up"
        keymap_global[modifier + "U0-L"] = modifier + "Right"
        # Home / End
        keymap_global[modifier + "U0-A"] = modifier + "Home"
        keymap_global[modifier + "U0-E"] = modifier + "End"
        # Enter
        keymap_global[modifier + "U0-Space"] = modifier + "Enter"

        # 「カタカナひらがなローマ」キーを徹底的に無視
        keymap_global["D-" + modifier + "(240)"] = lambda: None
        keymap_global["U-" + modifier + "(240)"] = lambda: None
        keymap_global["D-" + modifier + "(241)"] = lambda: None
        keymap_global["U-" + modifier + "(241)"] = lambda: None
        keymap_global["D-" + modifier + "(242)"] = lambda: None
        keymap_global["U-" + modifier + "(242)"] = lambda: None


    # [B]ackSpace / [D]elete
    keymap_global["U0-D"] = "Delete"
    keymap_global["U0-B"] = "Back"
    keymap_global["C-U0-D"] = "C-Delete"
    keymap_global["C-U0-B"] = "C-Back"
    keymap_global["S-U0-B"] = "S-Home", "C-X" # 先頭まで切り取り
    keymap_global["S-U0-D"] = "S-End", "C-X" # 末尾まで切り取り
    keymap_global["C-S-U0-B"] = "Home", "Back", "End" # 前の行と連結
    keymap_global["C-S-U0-D"] = "End", "Delete", "End" # 次の行と連結

```

修飾キーによるカーソル移動をまとめて定義する方法は [こちらのサイト](https://mobitan.hateblo.jp/entry/20081129/1227802480) を参考にしています。Autohotkey では `{blind}` をつけることで修飾キーを透過させられますね。


### IME 制御

IME オン／オフを 変換+J と 無変換+F で制御しています。 _Japanese_ と _Foreign_ で考えたところ、ちょうどキーにホームポジション用の突起があったので重宝しています。

```config.py
    # [J]apanese / [F]oreign
    keymap_global["U1-J"] = lambda: keymap.getWindow().setImeStatus(1)
    keymap_global["U0-F"] = lambda: keymap.getWindow().setImeStatus(0)

```

### その他キーリマップ

```config.py
    # 1行選択
    keymap_global["U1-A"] = "End", "S-Home"

    # 本来の再変換
    keymap_global["U0-R"] = "LWin-Slash"

    # emacs で見たやつ
    keymap_global["LC-H"] = "Back"

    # タスクバーにフォーカス
    keymap_global["C-U0-W"] = "W-T"

    # 上下に1行挿入
    keymap_global["U0-I"]   = "End", "Enter"
    keymap_global["S-U0-I"] = "Home", "Enter", "Up"

    # escape
    keymap_global["O-(235)"] = "Esc"
    keymap_global["U0-X"]    = "Esc"

    # 開いているウィンドウ一覧
    keymap_global["U0-W"] = "LCtrl-LAlt-Tab", "U-LAlt" # 押しっぱなし現象回避のために明示的に Alt を Up させる

    # 英数で確定
    keymap_global["U1-N"]   = "F10", "(243)"
    keymap_global["S-U1-N"] = "F10", "Enter"

    # コンテキストメニュー
    keymap_global["U0-C"] = "S-F10"

    # リネーム
    keymap_global["U0-N"] = "F2", "Right"
    keymap_global["S-U0-N"] = "F2", "C-Home"
    keymap_global["C-U0-N"] = "F2"

```

## 自作ホットキー

### 汎用関数

よく使う機能をホットキー化するために以下の関数を作成しておきます。

```config.py
    def delay(sec = 0.05):
        time.sleep(sec)

    def get_clippedText():
        return (getClipboardText() or "")

    def paste_string(s):
        setClipboardText(s)
        delay()
        keymap.InputKeyCommand("C-V")()

    def copy_string(sec = 0.05):
        keymap.InputKeyCommand("C-C")()
        delay(sec)
        return get_clippedText()

    def send_input(ime_mode, keys, sleep = 0.01):
        if ime_mode is not None:
            if keymap.getWindow().getImeStatus() != ime_mode:
                keymap.InputKeyCommand("(243)")()
        for key in keys:
            delay(sleep)
            try:
                keymap.InputKeyCommand(key)()
            except:
                keymap.InputTextCommand(key)()
```

keyhac ではキー入力の `InputKeyCommand()` と 文字列入力の `InputTextCommand()` が別に扱われます。 Autohotkey での `Send, ABC{Enter} 	` のような入力指定を実現するため、最後の `send_input()` 関数を作りました。あわせて IME も指定できるようにしています（`(243)` は半角／全角キー）。

### 文字入力系

上記の `send_input()` を使用して、できる限り文字入力の手間を減らしていきます。

#### 括弧類の入力

```config.py
    # 括弧類を入力して間にカーソルを移動させる
    def wrap_with_brackets(pair, after_ime_mode):
        keys = [pair, "Left"]
        if after_ime_mode == 1:
            keys.append("(243)")
        return lambda: send_input(0, keys, 0.05)
    brackets = [
        ("U0-2"            , '""'          , 0),
        ("U0-7"            , "''"          , 0),
        ("U0-8"            , "\u300E\u300F", 1), # WHITE CORNER BRACKET 『』
        ("U0-9"            , "\u3010\u3011", 1), # BLACK LENTICULAR BRACKET 【】
        ("U0-AtMark"       , "``"          , 0),
        ("U1-2"            , "\u201C\u201D", 1), # DOUBLE QUOTATION MARK “”
        ("U1-7"            , "\u3014\u3015", 1), # TORTOISE SHELL BRACKET 〔〕
        ("U1-8"            , "\uFF08\uFF09", 1), # FULLWIDTH PARENTHESIS （）
        ("U1-9"            , "()"          , 0),
        ("U0-OpenBracket"  , "\u300c\u300d", 1), # CORNER BRACKET 「」
        ("U1-OpenBracket"  , "\uFF3B\uFF3D", 1), # FULLWIDTH SQUARE BRACKET ［］
        ("U0-CloseBracket" , "[]"          , 0),
        ("U1-CloseBracket" , "{}"          , 0),
        ("C-U0-Comma"      , "<>"          , 0),
        ("C-U0-Period"     , "</>"         , 0),
        ("U0-Y"            , "\u3008\u3009", 1), # Angle Bracket 〈〉
        ("U1-Y"            , "\u300A\u300B", 1), # Double Angle Bracket 《》
    ]
    for brc in brackets:
        keymap_global[brc[0]] = wrap_with_brackets(brc[1], brc[2])
```

特殊括弧を unicode コードポイントで指定しているのはエディタ上での見栄えの問題です。ダイレクトに `send_input(0, ["U0-8"            , "『』", "(243)"])` などとしても問題はありません。

#### 記号の直接入力

句点や読点、コロン等をキーで入力してからさらに変換することは稀なので、直接入力できるようにしています。

仕組み的には単純で、入力時の IME 状態を見てオンであれば Ctrl+M （大抵の IME で確定のショートカット）を追加で押下しているだけです。 `@` など、その後ろに日本語を入力する機会が少ない文字は、入力後に自動で IME をオフできるようにしました。

Enter で確定しないのには理由があり、パスワード入力フォームなど、既に直接入力状態になっているときに Enter が誤って押されてしまわないための工夫です。とはいえ、 Ctrl+M に機能が割り振られているブラウザもあるので要注意です（ Firefox ではミュート機能のトグル）。Google 日本語入力であれば、再度半角／全角キー（243）を押すことでその時点の入力内容が確定しますが、汎用性を考えてこの方法を採用しています。

```config.py
    # IMEのオンオフに関係なく直接入力する
    def direct_input(key, turnoff_ime_later = False):
        key_list = [key]
        if keymap.getWindow().getImeStatus() == 1:
            key_list.append("C-M")
            if turnoff_ime_later:
                key_list.append("(243)")
        send_input(None, key_list)

    for key in [
        ("AtMark"      , True),
        ("Caret"       , False),
        ("CloseBracket", False),
        ("Colon"       , False),
        ("Comma"       , False),
        ("LS-AtMark"   , True),
        ("LS-Caret"    , False),
        ("LS-Colon"    , False),
        ("LS-Comma"    , False),
        ("LS-Minus"    , False),
        ("LS-Period"   , False),
        ("LS-SemiColon", False),
        ("LS-Slash"    , False),
        ("LS-Yen"      , True),
        ("OpenBracket" , False),
        ("Period"      , False),
        ("SemiColon"   , False),
        ("Slash"       , False),
        ("Yen"         , True),
    ]:
        def _wrapper(k, i):
            return lambda: direct_input(k, i)
        keymap_global[key[0]] = _wrapper(key[0], key[1])

    # 左シフト+数字キーでの記号
    for n in "123456789":
        def _wrapper(k, i):
            return lambda: direct_input(k, i)
        key = "LS-" + n
        if n in ("2", "3", "4"):
            keymap_global[key] = _wrapper(key, True)
        else:
            keymap_global[key] = _wrapper(key, False)

    # 左シフトで大文字アルファベットを入力した場合は以降の IME をオフにする
    for alphabet in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
        def _wrapper(k):
            return lambda: direct_input(k, True)
        key = "LS-" + alphabet
        keymap_global[key] = _wrapper(key)
```

ラムダ式をループで割り当てるときに `_wrapper()` を作る方法は [こちらのサイト](http://www.freia.jp/taka/blog/737/index.html) で勉強しました。

その他の記号類も直接入力できるようにしていきます（下記は一例）。上記の `send_input()` の第一引数で IME 状態を指定できるようにしたので、「IME オフ → 文字入力 → 半角／全角キー押下」とすることで擬似的に文字列を直接入力しています。

```config.py
    keymap_global["BackSlash"]      = lambda: direct_input("S-BackSlash", False)
    keymap_global["U0-Minus"]       = lambda: send_input(0, ["\u2015\u2015", "(243)"]) # HORIZONTAL BAR * 2
    keymap_global["U1-Minus"]       = lambda: send_input(0, ["Minus"])
    keymap_global["U0-U"]           = lambda: send_input(0, "_")
    keymap_global["U0-Colon"]       = lambda: send_input(0, ["Colon"])
    keymap_global["U0-Comma"]       = lambda: send_input(0, ["\uFF0C", "(243)"]) # FULLWIDTH COMMA ，
    keymap_global["U0-Period"]      = lambda: send_input(0, ["Period"])
    keymap_global["U0-Slash"]       = lambda: send_input(0, ["Slash"])

```

#### 日付入力

```config.py
    def input_date(fmt):
        d = datetime.datetime.today()
        if fmt == "jp":
            date_str = "{}年{}月{}日".format(d.year, d.month, d.day)
            send_input(0, [date_str, "(243)"])
        else:
            date_str = d.strftime(fmt)
            send_input(0, date_str, 0)
    keymap_global["U1-D"] = keymap.defineMultiStrokeKeymap("date format: 1=>YYYYMMDD, 2=>YYYY/MM/DD, 3=>YYYY.MM.DD, 4=>YYYY-MM-DD, 5=>YYYY年MM月DD日")
    keymap_global["U1-D"]["1"] = lambda: input_date(r"%Y%m%d")
    keymap_global["U1-D"]["2"] = lambda: input_date(r"%Y/%m/%d")
    keymap_global["U1-D"]["3"] = lambda: input_date(r"%Y.%m.%d")
    keymap_global["U1-D"]["4"] = lambda: input_date(r"%Y-%m-%d")
    keymap_global["U1-D"]["5"] = lambda: input_date("jp")

```




### 各種日常作業の効率化

```config.py
    # プレーンテキストとして貼り付け
    keymap_global["U0-V"] = lambda: paste_string(get_clippedText())

    # 空白文字を消してプレーンテキストとして貼り付け
    keymap_global["U1-V"] = lambda: paste_string(re.sub(r"\s", "", get_clippedText()))

    # IME オフのママ入力してしまったとき、直前の単語を選択して IME オン
    keymap_global["U1-Space"] = lambda: send_input(1, ["C-S-Left"])

    # 選択した URL を開く
    def open_url():
        url = (copy_string()).strip()
        if url.startswith("http"):
            run_url = url
        elif url.startswith("file:///"):
            local_path = url.replace("file:///", "")
            local_path = urllib.parse.unquote(local_path)
            if not os.path.exists(local_path):
                return None
            run_url = local_path
        else:
            return None
        keymap.ShellExecuteCommand("open", run_url, None, None)()
    keymap_global["D-U0-O"] = open_url

    # 選択した半角英数を IME オンで再入力
    def re_input_as_kana():
        origin = get_clippedText()
        if origin:
            setClipboardText("")
        selection = copy_string(0.1)
        if selection:
            key_list = []
            noblank = re.sub(r"\s", "", selection)
            for k in noblank:
                if k == "-":
                    key_list.append("Minus")
                else:
                    key_list.append(k)
            send_input(1, key_list, 0)
        if origin:
            setClipboardText(origin)
    keymap_global["U1-I"] = re_input_as_kana

```

### Web 検索系

選択状態で 無変換+S に続けてキーを押すと各種エンジンで検索します（正しくコピーされなかった場合は直前にクリップボードにあった文字列で検索）。

2番目のキーを押すときに Shift を押しながらだと各単語を二重引用符で囲んでキーワード検索し、Ctrl を押しながらだとひらがなを消して検索します。

Shift でキーワード検索しない限りカンマなどの約物をスペースに変換するようにしているので、文字列選択は多少適当でも大丈夫です。

```config.py
    def quote_each_word(s):
        ret = []
        for w in re.split(r"\s+", s):
            ret.append('"{}"'.format(w))
        return " ".join(ret)

    def punctuation_to_space(s):
        ret = re.sub(r"[\W_]+", " ", s)
        return ret.strip()

    def search_on_web(URLstr, mode = None):
        selection = copy_string(0.1)
        if len(selection) < 200:
            single_spaced = (re.sub(r"\s+", " ", selection)).strip()
            if mode == "strict":
                search_str = quote_each_word(single_spaced)
            else:
                search_str = punctuation_to_space(single_spaced)
                if mode == "without_hira":
                    search_str = re.sub(r"[ぁ-ん]+", " ", search_str)
                    search_str = re.sub(r"\s+", " ", search_str)
                    search_str = search_str.strip()
            encoded = urllib.parse.quote(search_str)
            keymap.ShellExecuteCommand("open", URLstr + encoded, None, None)()

    engine_url = [
        ("A", r"https://www.amazon.co.jp/s?i=stripbooks&k="),
        ("C", r"https://ci.nii.ac.jp/books/search?q="),
        ("E", r"http://webcatplus.nii.ac.jp/pro/?q="),
        ("G", r"http://www.google.co.jp/search?q="),
        ("I", r"https://www.google.com/search?tbm=isch&q="),
        ("M", r"https://www.google.co.jp/maps/search/"),
        ("N", r"https://iss.ndl.go.jp/books?any="),
        ("O", r"https://map.goo.ne.jp/search/q/"),
        ("S", r"https://scholar.google.co.jp/scholar?q="),
        ("T", r"https://twitter.com/search?f=live&q="),
        ("Y", r"http://www.google.co.jp/search?tbs=li:1&q=site%3Ayuhikaku.co.jp%20intitle%3A"),
        ("W", r"https://www.worldcat.org/search?q="),
    ]
    mode_modifier = [
        (""  , None),
        ("S-", "strict"),
        ("C-", "without_hira"),
    ]

    keymap_global["U0-S"] = keymap.defineMultiStrokeKeymap('S-:quote-each, C-:without-hiragana')
    for e in engine_url:
        for m in mode_modifier:
            search_key = m[0] + e[0]
            engine_url = e[1]
            mode = m[1]
            def _wrapper(url, md):
                return lambda: search_on_web(url, md)
            keymap_global["U0-S"][search_key] = _wrapper(engine_url, mode)

```

#### どこからでも google 検索

無変換+Q で、 chrome ウィンドウが開いている場合はアクティブにして新規タブを開き、開いてない場合は chrome を起動します。

`````config.py
    def find_window(arg_exe, arg_class):
        wnd = pyauto.Window.getDesktop().getFirstChild()
        last_found = None
        while wnd:
            if wnd.isVisible() and not wnd.getOwner():
                if wnd.getClassName() == arg_class and wnd.getProcessName() == arg_exe:
                    last_found = wnd
            wnd = wnd.getNext()
        return last_found

    def google_search():
        if keymap.getWindow().getProcessName() == "chrome.exe":
            send_input(1, ["C-T", "C-K"])
        else:
            wnd = find_window("chrome.exe", "Chrome_WidgetWin_1")
            if wnd:
                send_input(1, ["C-LWin-1", "C-T", "C-K"], 0.05)
            else:
                send_input(1, ["LWin-1"])
    keymap_global["U0-Q"] = google_search

```

用意されている `ActivateWindowCommand()` でウィンドウをアクティブにしようとすると、無視できない頻度で「タスクバーが点滅するだけでフォアグラウンドにならない」現象が起きたので、タスクバーに登録した chrome を Win+数字 で呼び出すようにしています。
（プログラムで強制的かつ確実にウィンドウを前面化できてしまうと悪質なウイルスに利用できてしまうのでセキュリティ上やむなしですね）

`pyauto.Window.find()` というウィンドウを特定するメソッドもあるのですが、引数として指定できるのはクラス名だけです。 `Chrome_WidgetWin_1` のウィンドウクラスを持つ slack や vscode などの Chromium 系アプリを chrome から識別できないので、開いているウィンドウをループして探索するようにしました。



---

普段づかいの内容に解説を付けただけでかなりの長さになってしまいました……。

このほか、クリップボードの文字列を加工して貼り付けたり、特定のウィンドウをアクティブにしたり、ウィンドウサイズを操作したりと諸々のカスタマイズをしていますが、どこまでも膨れてしまうので反響があれば（そしてやる気があれば）別記事にまとめようと思います。