ホットキーランチャー
キーボードショートカットにアプリ起動やコマンド実行を割り当てて管理するランチャーツール。
1. アプリ概要
キーボードショートカットにアプリ起動やコマンド実行を割り当てて管理するランチャーツール。
このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★★ です。
このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。
カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。
2. 機能一覧
- ループで生成するボタン群を
lambda v=v: v.set(filedialog.askopenfilename() or v.get())で接続 - 「+ 追加」ボタン(
_add())・「✏ 編集」ボタン(_edit())・「🗑 削除」ボタン(_delete())・「▶ 実行」ボタン(_run_selected())・「🔃 ホットキー再登録」ボタン(_rebind_all())・「保存」ボタンで操作 - マウス操作
<Double-1>から_run_selected()を実行 ttk.Treeviewによる表形式の一覧表示- JSON ファイルの保存・読み込み(起動時に自動実行)
filedialog.askopenfilename()によるファイル選択ダイアログ- ウィンドウはタイトル「ホットキーランチャー」・サイズ 760x500 で起動
3. 事前準備・環境
Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動を確認(ホットキー登録は Windows のみ・macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では、keyboard を入れずに起動しました(1章の図の上部の警告バーがその状態です)。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。
- Python 3.10 以上
- OS: Windows 11(実機で起動・基本操作を確認)/Linux Mint 22.3(仮想マシンで起動を確認)
keyboard は Linux では入力デバイスを直接読むため、root 権限(または input グループへの所属)が要ります。権限が足りないとホットキーの登録で失敗します。当サイトの Linux Mint 22.3 では、keyboard 無しで起動して警告バーが出る状態を確認しました。ホットキーの登録と実行を確認したのは Windows 11 だけです。
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import subprocess
import threading
import os
import json
try:
import keyboard
KEYBOARD_AVAILABLE = True
except ImportError:
KEYBOARD_AVAILABLE = False
SAVE_PATH = os.path.join(os.path.dirname(__file__), "hotkey_launcher.json")
class App084:
"""ホットキーランチャー"""
def __init__(self, root):
self.root = root
self.root.title("ホットキーランチャー")
self.root.geometry("760x500")
self.root.configure(bg="#1e1e1e")
self._entries = [] # [{"name": ..., "hotkey": ..., "command": ..., "enabled": bool}]
self._hooks = {} # {hotkey_str: hook_obj}
self._build_ui()
self._load()
if not KEYBOARD_AVAILABLE:
pass # 警告ラベルはビルド時に表示済み
def _build_ui(self):
header = tk.Frame(self.root, bg="#252526", pady=6)
header.pack(fill=tk.X)
tk.Label(header, text="⌨ ホットキーランチャー",
font=("Noto Sans JP", 12, "bold"),
bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
if not KEYBOARD_AVAILABLE:
tk.Label(self.root,
text="⚠ keyboard が未インストールです (pip install keyboard) ※管理者権限が必要",
bg="#fff3cd", fg="#856404", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X)
# ツールバー
tb = tk.Frame(self.root, bg="#2d2d2d", pady=4)
tb.pack(fill=tk.X)
tk.Button(tb, text="+ 追加", command=self._add,
bg="#2e7d32", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#1b5e20", bd=0).pack(side=tk.LEFT, padx=4)
tk.Button(tb, text="✏ 編集", command=self._edit,
bg="#1565c0", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#0d47a1", bd=0).pack(side=tk.LEFT, padx=2)
tk.Button(tb, text="🗑 削除", command=self._delete,
bg="#c62828", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#b71c1c", bd=0).pack(side=tk.LEFT, padx=2)
tk.Button(tb, text="▶ 実行", command=self._run_selected,
bg="#f57c00", fg="white", relief=tk.FLAT,
font=("Arial", 10), padx=12, pady=4,
activebackground="#e65100", bd=0).pack(side=tk.LEFT, padx=8)
ttk.Button(tb, text="🔃 ホットキー再登録",
command=self._rebind_all).pack(side=tk.LEFT, padx=4)
# ランチャーリスト
cols = ("enabled", "name", "hotkey", "command")
self.tree = ttk.Treeview(self.root, columns=cols, show="headings",
selectmode="browse")
self.tree.heading("enabled", text="有効")
self.tree.heading("name", text="名前")
self.tree.heading("hotkey", text="ホットキー")
self.tree.heading("command", text="コマンド / パス")
self.tree.column("enabled", width=50, anchor="center")
self.tree.column("name", width=160, anchor="w")
self.tree.column("hotkey", width=120, anchor="center")
self.tree.column("command", width=380, anchor="w")
tsb = ttk.Scrollbar(self.root, command=self.tree.yview)
self.tree.configure(yscrollcommand=tsb.set)
tsb.pack(side=tk.RIGHT, fill=tk.Y)
self.tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
self.tree.bind("<Double-1>", lambda e: self._run_selected())
# ログ
log_f = tk.Frame(self.root, bg="#0d1117")
log_f.pack(fill=tk.X, padx=8, pady=2)
self.log_text = tk.Text(log_f, height=4, bg="#0d1117", fg="#8b949e",
font=("Courier New", 8), relief=tk.FLAT,
state=tk.DISABLED)
self.log_text.pack(fill=tk.X)
self.status_var = tk.StringVar(value="ホットキーを追加してください")
tk.Label(self.root, textvariable=self.status_var,
bg="#252526", fg="#858585", font=("Arial", 9),
anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)
def _log(self, msg):
self.log_text.configure(state=tk.NORMAL)
self.log_text.insert(tk.END, msg + "\n")
self.log_text.see(tk.END)
self.log_text.configure(state=tk.DISABLED)
def _refresh(self):
self.tree.delete(*self.tree.get_children())
for e in self._entries:
self.tree.insert("", tk.END,
values=("✔" if e["enabled"] else "✘",
e["name"], e["hotkey"], e["command"]))
def _add(self):
self._edit_dialog(None)
def _edit(self):
sel = self.tree.selection()
if not sel:
return
idx = self.tree.index(sel[0])
self._edit_dialog(idx)
def _edit_dialog(self, idx):
"""追加/編集ダイアログ"""
entry = self._entries[idx] if idx is not None else {
"name": "", "hotkey": "ctrl+alt+a", "command": "", "enabled": True}
dlg = tk.Toplevel(self.root)
dlg.title("ホットキー設定")
dlg.configure(bg="#252526")
dlg.grab_set()
fields = [
("名前", "name", entry["name"]),
("ホットキー", "hotkey", entry["hotkey"]),
("コマンド", "command", entry["command"]),
]
vars_ = {}
for label, key, default in fields:
row = tk.Frame(dlg, bg="#252526")
row.pack(fill=tk.X, padx=12, pady=4)
tk.Label(row, text=f"{label}:", bg="#252526", fg="#ccc",
width=10, anchor="w").pack(side=tk.LEFT)
v = tk.StringVar(value=default)
vars_[key] = v
ttk.Entry(row, textvariable=v, width=32).pack(side=tk.LEFT, padx=4)
if key == "command":
ttk.Button(row, text="参照",
command=lambda v=v: v.set(
filedialog.askopenfilename() or v.get())
).pack(side=tk.LEFT)
en_var = tk.BooleanVar(value=entry["enabled"])
tk.Checkbutton(dlg, text="有効", variable=en_var,
bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
activebackground="#252526").pack(padx=12, pady=4, anchor="w")
def _ok():
new_entry = {
"name": vars_["name"].get(),
"hotkey": vars_["hotkey"].get(),
"command": vars_["command"].get(),
"enabled": en_var.get(),
}
if idx is None:
self._entries.append(new_entry)
else:
# 古いホットキーを解除
if KEYBOARD_AVAILABLE:
old_hk = entry["hotkey"]
if old_hk in self._hooks:
try:
keyboard.remove_hotkey(self._hooks[old_hk])
except Exception:
pass
del self._hooks[old_hk]
self._entries[idx] = new_entry
self._refresh()
self._save()
self._bind_entry(new_entry)
dlg.destroy()
ttk.Button(dlg, text="保存", command=_ok).pack(pady=8)
def _delete(self):
sel = self.tree.selection()
if not sel:
return
idx = self.tree.index(sel[0])
e = self._entries[idx]
if KEYBOARD_AVAILABLE and e["hotkey"] in self._hooks:
try:
keyboard.remove_hotkey(self._hooks[e["hotkey"]])
except Exception:
pass
del self._hooks[e["hotkey"]]
del self._entries[idx]
self._refresh()
self._save()
def _run_selected(self):
sel = self.tree.selection()
if not sel:
return
idx = self.tree.index(sel[0])
self._run(self._entries[idx])
def _run(self, entry):
cmd = entry["command"].strip()
if not cmd:
return
self._log(f"実行: {entry['name']} [{cmd}]")
self.status_var.set(f"実行: {entry['name']}")
threading.Thread(target=lambda: subprocess.Popen(
cmd, shell=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0),
daemon=True).start()
def _bind_entry(self, entry):
if not KEYBOARD_AVAILABLE or not entry["enabled"] or not entry["hotkey"]:
return
try:
hook = keyboard.add_hotkey(entry["hotkey"],
lambda e=entry: self.root.after(0, self._run, e))
self._hooks[entry["hotkey"]] = hook
self._log(f"登録: {entry['hotkey']} → {entry['name']}")
except Exception as ex:
self._log(f"登録失敗: {entry['hotkey']} ({ex})")
def _rebind_all(self):
if KEYBOARD_AVAILABLE:
for hook in self._hooks.values():
try:
keyboard.remove_hotkey(hook)
except Exception:
pass
self._hooks.clear()
for e in self._entries:
self._bind_entry(e)
self.status_var.set("ホットキーを再登録しました")
def _save(self):
try:
with open(SAVE_PATH, "w", encoding="utf-8") as f:
json.dump(self._entries, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _load(self):
if not os.path.exists(SAVE_PATH):
self._entries = [
{"name": "メモ帳", "hotkey": "ctrl+alt+n",
"command": "notepad.exe", "enabled": True},
{"name": "電卓", "hotkey": "ctrl+alt+c",
"command": "calc.exe", "enabled": True},
]
else:
try:
with open(SAVE_PATH, encoding="utf-8") as f:
self._entries = json.load(f)
except Exception:
self._entries = []
self._refresh()
self._rebind_all()
if __name__ == "__main__":
root = tk.Tk()
app = App084(root)
root.mainloop()
5. コード解説
ホットキーランチャーのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App084 クラスにアプリの全機能をまとめています(メソッド14個・全269行)。__init__ ではタイトル「ホットキーランチャー」とウィンドウサイズ 760x500 を設定します。そのあと _build_ui() → _load() の順に呼び出して初期状態を作ります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×3(ほかにループでも生成)・tk.Label×3(ほかにループでも生成)・tk.Button×4・ttk.Button×2(ほかにループでも生成)・ttk.Treeview・ttk.Scrollbar・tk.Text・tk.Toplevel・ttk.Entry(ループで生成)・tk.Checkbutton で構成しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「+ 追加」ボタン(_add())・「✏ 編集」ボタン(_edit())・「🗑 削除」ボタン(_delete())・「▶ 実行」ボタン(_run_selected())・「🔃 ホットキー再登録」ボタン(_rebind_all())・「保存」ボタンを command= で接続しています。ボタンはループ内で command=lambda v=v: v.set(filedialog.askopenfilename() or v.get()) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<Double-1>", ...) から _run_selected() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
JSON への保存と読み込み
データは json.dump() でファイル(hotkey_launcher.json)へ保存します。起動時は json.load() で読み戻します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で Exception・ImportError を捕捉しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app084.py と保存します。外部ライブラリ
keyboardを使います。先にpip install keyboardを実行してください。 -
2クラスの骨格を作る
App084クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("ホットキーランチャー")・geometry("760x500")・configure(bg="#1e1e1e")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×3(ほかにループでも生成)・tk.Label×3(ほかにループでも生成)・tk.Button×4・ttk.Button×2(ほかにループでも生成)・ttk.Treeview・ttk.Scrollbar・tk.Text・tk.Toplevel・ttk.Entry(ループで生成)・tk.Checkbuttonを作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_add()・_edit()・_delete()・_run_selected()・_rebind_all()・_ok)につなぎます。ループ生成のボタンはcommand=lambda v=v: v.set(filedialog.askopenfilename() or v.get())の形で接続します。bind("<Double-1>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_run()(10行)・_delete()(15行)・_rebind_all()(11行)など を実装します。 -
7動作確認する
python app084.pyで起動し、各ボタンが反応すること・マウス操作(クリック・ホイールなど)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 配色をまとめて管理する
背景色 #1e1e1e をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。
💡 設定ダイアログ
フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ 写経したら全部のボタンが同じ動きになる
原因:ループでボタンを作るとき、このコードは command=lambda v=v: v.set(filedialog.askopenfilename() or v.get()) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。
解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("760x500") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは、入力した文字列を subprocess.Popen(cmd, shell=True)(213-216行)でそのまま実行します。登録するのは、自分で中身を説明できるコマンドだけにしてください。設定は同じフォルダの hotkey_launcher.json に保存され、起動時に読み込んで自動でホットキーに割り当てます(248-263行)。配布元の分からない hotkey_launcher.json をこのフォルダへ置かないでください。keyboard はキー入力を横取りするしくみで、Linux では root 権限(または input グループへの所属)が要ります(3章の注意書きを参照)。Windows では通常そのまま登録できますが、管理者として起動した別のアプリの上ではホットキーが効かないことがあります。ホットキーの登録と実行を確認したのは Windows 11 だけです。
-
課題1:同じホットキーを2つの項目に割り当てられてしまう
「+ 追加」で、すでに使われているホットキーをそのまま保存できます。警告もエラーも出ません。一覧には同じ組み合わせが2行並びます。
期待結果:最初から入っている「メモ帳」のホットキーは
ctrl+alt+n。「+ 追加」で名前だけ変えた項目を同じctrl+alt+nで保存すると、一覧にctrl+alt+nの行が2つ並ぶ。直したあとは、保存しようとした時点で「そのホットキーは『メモ帳』が使っています」と知らせて、重複した行を作らない。合格条件
- すでに使われているホットキーで保存しようとしたら、知らせて追加しない
- 編集で、自分の行のホットキーを変えずに名前だけ直したときは重複と判定しない
- ホットキー欄が空の項目は、今までどおり保存できる
ヒント①(どこを触るか): 触るのは1か所です。
_edit_dialog()の中のdef _ok():(157行)。一覧へ入れる165行・176行の手前に、確認を足します。
ヒント②(使うもの):self._entriesは辞書のリストです。any(x["hotkey"] == new_entry["hotkey"] for i, x in enumerate(self._entries) if i != idx)のような判定が書けます。重複していたらmessagebox.showwarning(...)を出してreturnします。ダイアログは開いたままなので、入力し直せます(messageboxは2行目で読み込み済み)。
つまずきやすい点: 追加のときはidxがNoneなので、i != idxはすべての行で成り立ちます(全件が比較の対象になり、これは意図どおりです)。編集のときだけ自分の行が除かれます。self._hooksはホットキーの文字列をキーにした辞書なので(224行)、同じ組み合わせを2回登録すると、あとから入れたほうだけが辞書に残ります。先に登録したほうはkeyboard.remove_hotkey()に渡せなくなり、「🗑 削除」を押しても解除されません。 -
課題2:コマンドの実行に失敗しても、ログには「実行」としか出ない
「▶ 実行」を押すと、ログに
実行: メモ帳 [notepad.exe]の1行が出ます。この行はコマンドを起動する前に書かれています。名前を打ち間違えていても、同じ1行が出るだけです。期待結果:「+ 追加」で名前を
テスト、コマンドをnotepad_typo.exeにして保存する。「▶ 実行」を押すと、ログは実行: テスト [notepad_typo.exe]の1行だけ。shell=Trueなのでシェル自体は起動でき、失敗はシェルの側で起きる。しかもCREATE_NO_WINDOW(215行)で黒い画面が出ないため、エラーの文言はどこにも表示されない。直したあとは終了: テスト (コード 1)のように結果まで分かる(この数値は OS やシェルで変わります)。合格条件
- 実行できたときは、終了コード
0が分かる行がログに増える - 失敗したときは、
0以外の終了コードが分かる行がログに増える - 「▶ 実行」を押した直後に画面が固まらない(今までどおり別スレッドで待つ)
ヒント①(どこを触るか): 触るのは1か所です。
_run()のthreading.Thread(target=lambda: subprocess.Popen(...))(213-216行)。
ヒント②(使うもの):lambdaをやめて関数にします。proc = subprocess.Popen(...)のあとにcode = proc.wait()を書けば、終了コードが取れます。ログへ書くときはself.root.after(0, self._log, f"終了: {entry['name']} (コード {code})")の形にしてください。222-223行のkeyboard.add_hotkey()が、同じafter()でメインスレッドへ戻す手本です。
つまずきやすい点:_log()(99-103行)は Text ウィジェットを操作するので、ワーカースレッドから直接呼ばないでください。proc.wait()を書いた行から先は、コマンドが終わるまで進みません。別スレッドなので、その間も画面は動き続けます。shell=Trueでは、コマンド名が存在しなくてもPopenは例外を投げず、シェルが 0 以外の終了コードを返します。creationflagsの行は条件式です。Windows 以外ではsubprocess.CREATE_NO_WINDOWを読まずに0が使われます。 - 実行できたときは、終了コード
-
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。