keyhac 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. keyhac/__init__.py +70 -0
  2. keyhac/__main__.py +4 -0
  3. keyhac/_config.py +372 -0
  4. keyhac/actions.py +622 -0
  5. keyhac/core/__init__.py +0 -0
  6. keyhac/core/action.py +121 -0
  7. keyhac/core/clipboard_history.py +113 -0
  8. keyhac/core/config.py +29 -0
  9. keyhac/core/const.py +81 -0
  10. keyhac/core/focus.py +130 -0
  11. keyhac/core/input.py +203 -0
  12. keyhac/core/key.py +144 -0
  13. keyhac/core/keymap.py +546 -0
  14. keyhac/core/log.py +154 -0
  15. keyhac/core/replay.py +80 -0
  16. keyhac/core/settings.py +64 -0
  17. keyhac/core/vk.py +393 -0
  18. keyhac/main.py +181 -0
  19. keyhac/platform/__init__.py +0 -0
  20. keyhac/platform/base.py +307 -0
  21. keyhac/platform/fake.py +99 -0
  22. keyhac/platform/mac/__init__.py +26 -0
  23. keyhac/platform/mac/apps.py +70 -0
  24. keyhac/platform/mac/clipboard.py +27 -0
  25. keyhac/platform/mac/focus.py +102 -0
  26. keyhac/platform/mac/hook.py +586 -0
  27. keyhac/platform/mac/instance.py +76 -0
  28. keyhac/platform/mac/loop.py +40 -0
  29. keyhac/platform/mac/uielement.py +144 -0
  30. keyhac/platform/mac/window.py +214 -0
  31. keyhac/platform/win/__init__.py +24 -0
  32. keyhac/platform/win/apps.py +86 -0
  33. keyhac/platform/win/clipboard.py +95 -0
  34. keyhac/platform/win/focus.py +220 -0
  35. keyhac/platform/win/hook.py +425 -0
  36. keyhac/platform/win/instance.py +98 -0
  37. keyhac/platform/win/loop.py +90 -0
  38. keyhac/platform/win/uielement.py +547 -0
  39. keyhac/platform/win/window.py +350 -0
  40. keyhac/ui/__init__.py +0 -0
  41. keyhac/ui/assets/MenuExtraTemplate.png +0 -0
  42. keyhac/ui/assets/MenuExtraTemplate@2x.png +0 -0
  43. keyhac/ui/assets/keyhac.icns +0 -0
  44. keyhac/ui/assets/keyhac.ico +0 -0
  45. keyhac/ui/balloon.py +59 -0
  46. keyhac/ui/chooser.py +138 -0
  47. keyhac/ui/console.py +300 -0
  48. keyhac/ui/runtime.py +6 -0
  49. keyhac/ui/tray.py +55 -0
  50. keyhac-2.0.0.dist-info/METADATA +141 -0
  51. keyhac-2.0.0.dist-info/RECORD +55 -0
  52. keyhac-2.0.0.dist-info/WHEEL +5 -0
  53. keyhac-2.0.0.dist-info/entry_points.txt +2 -0
  54. keyhac-2.0.0.dist-info/licenses/LICENSE +21 -0
  55. keyhac-2.0.0.dist-info/top_level.txt +1 -0
keyhac/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """Keyhac 2 - Python-scriptable keyboard customization for Windows and macOS.
2
+
3
+ Configuration files do `from keyhac import *` and get the user-facing API.
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+
8
+ from keyhac.core.keymap import Keymap
9
+ from keyhac.core.key import KeyCondition, KeyTable
10
+ from keyhac.core.focus import FocusCondition
11
+ from keyhac.core.input import InputContext
12
+ from keyhac.core.log import getLogger, Console
13
+ from keyhac.core.action import (
14
+ ThreadedAction, LaunchApplication, InputText,
15
+ StartRecordingKeys, StopRecordingKeys, ToggleRecordingKeys,
16
+ PlaybackRecordedKeys,
17
+ )
18
+ from keyhac.core.clipboard_history import ClipboardHistory
19
+ from keyhac.actions import (
20
+ ActivateWindow,
21
+ MouseButtonClick,
22
+ MouseButtonDown,
23
+ MouseButtonUp,
24
+ MouseHorizontalWheel,
25
+ MouseMove,
26
+ MouseWheel,
27
+ MoveWindow,
28
+ SnapWindow,
29
+ ChooserAction,
30
+ DateTimeSnippet,
31
+ ShowClipboardHistory,
32
+ ShowClipboardSnippets,
33
+ ShowClipboardTools,
34
+ )
35
+ from keyhac.platform.base import Focus, KeyEvent
36
+
37
+ __all__ = [
38
+ "Keymap",
39
+ "KeyTable",
40
+ "KeyCondition",
41
+ "FocusCondition",
42
+ "InputContext",
43
+ "Focus",
44
+ "KeyEvent",
45
+ "ThreadedAction",
46
+ "LaunchApplication",
47
+ "InputText",
48
+ "StartRecordingKeys",
49
+ "StopRecordingKeys",
50
+ "ToggleRecordingKeys",
51
+ "PlaybackRecordedKeys",
52
+ "ClipboardHistory",
53
+ "ChooserAction",
54
+ "MouseButtonClick",
55
+ "MouseButtonDown",
56
+ "MouseButtonUp",
57
+ "MouseHorizontalWheel",
58
+ "MouseMove",
59
+ "MouseWheel",
60
+ "MoveWindow",
61
+ "SnapWindow",
62
+ "ActivateWindow",
63
+ "ShowClipboardHistory",
64
+ "ShowClipboardSnippets",
65
+ "ShowClipboardTools",
66
+ "DateTimeSnippet",
67
+ "getLogger",
68
+ "Console",
69
+ "__version__",
70
+ ]
keyhac/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from keyhac.main import main
2
+ import sys
3
+
4
+ sys.exit(main())
keyhac/_config.py ADDED
@@ -0,0 +1,372 @@
1
+ """Keyhac 2 configuration file.
2
+
3
+ This file is copied to ~/.keyhac/config.py on first run. Edit that copy —
4
+ Keyhac reloads it from the tray menu or the console's hook toggle.
5
+
6
+ The same file runs on Windows and macOS. Where the OSes genuinely differ,
7
+ branch on `keymap.platform`; the two constants set up at the top (LEADER and
8
+ MOD) absorb most of it, so the samples below rarely have to.
9
+
10
+ Everything here is a working example, not pseudo-code. Delete what you do
11
+ not want.
12
+ """
13
+
14
+ import json
15
+
16
+ from keyhac import *
17
+
18
+ logger = getLogger("Config")
19
+
20
+
21
+ def configure(keymap):
22
+
23
+ mac = keymap.platform == "mac"
24
+
25
+ # ==================================================================
26
+ # Setup
27
+ # ==================================================================
28
+
29
+ # --- user modifier -------------------------------------------------
30
+ # Turn a key into User0, a modifier of your own that no application
31
+ # sees. User0-User3 are available; a key used this way is never
32
+ # emitted, so it loses its original meaning while defined.
33
+ if mac:
34
+ # The right Option key; it stops acting as Option.
35
+ keymap.define_modifier("RAlt", "RUser0")
36
+ else:
37
+ # The left Windows key; the Start menu no longer opens on a tap
38
+ # (bind kt["O-LWin"] = "LWin" below if you want that back).
39
+ keymap.define_modifier("LWin", "LUser0")
40
+
41
+ # --- the two portability constants ---------------------------------
42
+ # LEADER: the modifier most samples below hang off.
43
+ # macOS - the Fn key, which Windows does not expose to software
44
+ # Windows - User0, i.e. the left Windows key defined just above
45
+ LEADER = "Fn" if mac else "User0"
46
+
47
+ # MOD: the OS's primary shortcut modifier, so one binding can mean
48
+ # "Cmd-C" on macOS and "Ctrl-C" on Windows.
49
+ MOD = "Cmd" if mac else "Ctrl"
50
+
51
+ # --- swap a key entirely (uncomment to try) ------------------------
52
+ # replace_key runs before any key table, so the rest of the config
53
+ # only ever sees the replacement.
54
+ # keymap.replace_key("CapsLock", "LCtrl")
55
+
56
+ # --- text editor for "Edit Config" ---------------------------------
57
+ # The tray menu's "Edit Config" opens this file. Unset, a default is
58
+ # picked (VS Code / Xcode / TextEdit on macOS, Notepad on Windows).
59
+ # Name an editor application, or set a callable taking the path:
60
+ # keymap.editor = "CotEditor" if mac else "notepad.exe"
61
+
62
+ # --- clipboard history ---------------------------------------------
63
+ keymap.clipboard_history.max_items = 500
64
+ keymap.clipboard_history.max_data_size = 10 * 1024 * 1024
65
+
66
+ # ==================================================================
67
+ # Global key table (active everywhere)
68
+ # ==================================================================
69
+
70
+ kt = keymap.define_keytable(focus_path_pattern="*")
71
+
72
+ # --- key -> key ----------------------------------------------------
73
+ # IJKL as arrow keys while LEADER is held.
74
+ kt[f"{LEADER}-I"] = "Up"
75
+ kt[f"{LEADER}-J"] = "Left"
76
+ kt[f"{LEADER}-K"] = "Down"
77
+ kt[f"{LEADER}-L"] = "Right"
78
+ kt[f"{LEADER}-U"] = "Home"
79
+ kt[f"{LEADER}-O"] = "End"
80
+
81
+ # --- key -> sequence of keys ---------------------------------------
82
+ # Select the whole line: Home, then Shift-End.
83
+ kt[f"{LEADER}-A"] = "Home", "Shift-End"
84
+
85
+ # --- one-shot: tap for one key, hold to modify ---------------------
86
+ # A one-shot key held down still works as its modifier; only a lone
87
+ # tap-and-release fires the assignment. Picked so a stray tap is
88
+ # harmless (an Escape here, say, would cancel dialogs mid-typing).
89
+ if mac:
90
+ # The classic macOS setup: tap Left/Right Cmd alone for Eisu/Kana
91
+ # (IME off/on) - a no-op unless a Japanese input source is
92
+ # installed. Held, they are still plain Cmd.
93
+ kt["O-LCmd"] = "Eisu"
94
+ kt["O-RCmd"] = "Kana"
95
+ else:
96
+ # Tap right Ctrl alone to open the Start-menu search; held, it is
97
+ # still plain Ctrl.
98
+ kt["O-RCtrl"] = "Win-S"
99
+
100
+ # --- key -> your own function --------------------------------------
101
+ def hello():
102
+ # print() and the logger both reach the console window.
103
+ print("Hello from config.py")
104
+ logger.info(f"platform={keymap.platform}")
105
+
106
+ kt[f"{LEADER}-H"] = hello
107
+
108
+ # --- typing literal text -------------------------------------------
109
+ kt[f"{LEADER}-Semicolon"] = InputText("me@example.com")
110
+
111
+ # --- sending keys from your own function ---------------------------
112
+ # get_input_context() batches virtual key input; it is safe to use from
113
+ # a worker thread as well (see the ThreadedAction sample below).
114
+ def wrap_in_quotes():
115
+ with keymap.get_input_context() as ctx:
116
+ ctx.send_key(f"{MOD}-C")
117
+
118
+ kt[f"{LEADER}-Q"] = wrap_in_quotes
119
+
120
+ # ==================================================================
121
+ # Clipboard
122
+ # ==================================================================
123
+
124
+ # --- history, in a chooser window ----------------------------------
125
+ # Enter pastes; Shift-Enter only sets the clipboard. Type to filter.
126
+ kt[f"{LEADER}-V"] = ShowClipboardHistory()
127
+
128
+ # --- fixed snippets -------------------------------------------------
129
+ # (icon, label) | (icon, label, text) | (icon, label, callable)
130
+ kt[f"{LEADER}-Shift-V"] = ShowClipboardSnippets([
131
+ ("📧", "me@example.com"),
132
+ ("📮", "Mailing address", "400 Broad St, Seattle, WA 98109"),
133
+ ("🕒", "Date", DateTimeSnippet("%Y-%m-%d")),
134
+ ("🕒", "Timestamp", DateTimeSnippet("%Y-%m-%d %H:%M:%S")),
135
+ ("🕒", "For filenames", DateTimeSnippet("%Y%m%d_%H%M%S")),
136
+ ])
137
+
138
+ # --- transform whatever is on the clipboard -------------------------
139
+ # A tool takes the clipboard text and returns the replacement.
140
+ def pretty_json(s):
141
+ try:
142
+ return json.dumps(json.loads(s), indent=4, ensure_ascii=False)
143
+ except json.JSONDecodeError:
144
+ logger.error("Clipboard content is not valid JSON.")
145
+ return s
146
+
147
+ kt[f"{LEADER}-Ctrl-V"] = ShowClipboardTools([
148
+ ("🔄", "Quote", ShowClipboardTools.quote),
149
+ ("🔄", "Unindent", ShowClipboardTools.unindent),
150
+ ("🔄", "Upper case", str.upper),
151
+ ("🔄", "Lower case", str.lower),
152
+ ("🔄", "Half width", ShowClipboardTools.to_half_width),
153
+ ("🔄", "Full width", ShowClipboardTools.to_full_width),
154
+ ("🔄", "Pretty JSON", pretty_json),
155
+ ])
156
+
157
+ # ==================================================================
158
+ # Windows and applications
159
+ # ==================================================================
160
+
161
+ # --- move the focused window ---------------------------------------
162
+ # Apple keyboards translate Fn-Arrow into Home/End/PageUp/PageDown in
163
+ # hardware (the Fn modifier itself still arrives), so with LEADER = Fn
164
+ # the "...-Left" spellings would never fire on macOS - bind the keys
165
+ # that actually arrive there. Ctrl/Alt rather than Shift, because
166
+ # Fn-Shift-Arrow is how you *select text* on a Mac laptop (it arrives
167
+ # as Shift-Home etc.) - a Shift binding here would steal it.
168
+ LEFT, RIGHT, UP, DOWN = (("Home", "End", "PageUp", "PageDown") if mac
169
+ else ("Left", "Right", "Up", "Down"))
170
+
171
+ # Nudge by 20 px...
172
+ kt[f"{LEADER}-Ctrl-{LEFT}"] = MoveWindow(direction="left", distance=20)
173
+ kt[f"{LEADER}-Ctrl-{RIGHT}"] = MoveWindow(direction="right", distance=20)
174
+ kt[f"{LEADER}-Ctrl-{UP}"] = MoveWindow(direction="up", distance=20)
175
+ kt[f"{LEADER}-Ctrl-{DOWN}"] = MoveWindow(direction="down", distance=20)
176
+
177
+ # ...or send it as far as it goes, stopping at other windows' edges
178
+ # and screen edges (and hopping to the next monitor when already there).
179
+ kt[f"{LEADER}-Alt-{LEFT}"] = MoveWindow(direction="left", distance=9999,
180
+ window_edge=True, screen_edge=True)
181
+ kt[f"{LEADER}-Alt-{RIGHT}"] = MoveWindow(direction="right", distance=9999,
182
+ window_edge=True, screen_edge=True)
183
+ kt[f"{LEADER}-Alt-{UP}"] = MoveWindow(direction="up", distance=9999,
184
+ window_edge=True, screen_edge=True)
185
+ kt[f"{LEADER}-Alt-{DOWN}"] = MoveWindow(direction="down", distance=9999,
186
+ window_edge=True, screen_edge=True)
187
+
188
+ # --- snap to screen regions (tiling) --------------------------------
189
+ # Resizes to a half of the window's current screen, inside the work
190
+ # area (menu bar, Dock and taskbar stay uncovered). Same IJKL layout
191
+ # as the arrows above. ratio= picks a different split, e.g.
192
+ # SnapWindow("left", ratio=2/3).
193
+ kt[f"{LEADER}-Ctrl-J"] = SnapWindow("left")
194
+ kt[f"{LEADER}-Ctrl-L"] = SnapWindow("right")
195
+ kt[f"{LEADER}-Ctrl-I"] = SnapWindow("top")
196
+ kt[f"{LEADER}-Ctrl-K"] = SnapWindow("bottom")
197
+ kt[f"{LEADER}-F"] = SnapWindow("full")
198
+
199
+ # --- minimize the focused window -------------------------------------
200
+ def minimize_window():
201
+ window = keymap.get_active_window()
202
+ if window is not None:
203
+ window.minimize()
204
+
205
+ kt[f"{LEADER}-M"] = minimize_window
206
+
207
+ # --- bring an application forward -----------------------------------
208
+ # Matches like the focus conditions below: wildcards, "|" alternation,
209
+ # case-insensitive, ".exe" optional.
210
+ kt[f"{LEADER}-1"] = ActivateWindow(app="code|Visual Studio Code")
211
+ kt[f"{LEADER}-2"] = ActivateWindow(app="chrome|Google Chrome")
212
+
213
+ # --- launch an application ------------------------------------------
214
+ if mac:
215
+ kt[f"{LEADER}-T"] = LaunchApplication("Terminal.app")
216
+ else:
217
+ kt[f"{LEADER}-T"] = LaunchApplication("wt.exe") # Windows Terminal
218
+
219
+ # --- inspect windows yourself ---------------------------------------
220
+ # keymap.get_active_window() / find_window() / list_windows() return
221
+ # portable Window objects: title, app_name, pid, class_name (Windows),
222
+ # get_frame(), set_frame(), activate(), minimize(), is_minimized(),
223
+ # restore(). Screen geometry: keymap.screen_frames() (whole screens),
224
+ # keymap.screen_work_frames() (minus menu bar / Dock / taskbar) and
225
+ # keymap.window_frames(). Window objects and screen_work_frames() are
226
+ # UI-thread only - never touch them from a ThreadedAction.run(); the
227
+ # thread-safe pair there is screen_frames() / window_frames().
228
+ def describe_window():
229
+ window = keymap.get_active_window()
230
+ if window is None:
231
+ logger.warning("No active window.")
232
+ return
233
+ x, y, w, h = window.get_frame()
234
+ logger.info(f"{window.app_name}: \"{window.title}\" "
235
+ f"at ({x:.0f},{y:.0f}) {w:.0f}x{h:.0f}")
236
+ logger.info(f"{len(keymap.list_windows())} windows open on "
237
+ f"{len(keymap.screen_frames())} screen(s)")
238
+
239
+ kt[f"{LEADER}-W"] = describe_window
240
+
241
+ # --- activate, or launch if it is not running ------------------------
242
+ def activate_or_launch_editor():
243
+ window = keymap.find_window(app="code|Visual Studio Code")
244
+ if window:
245
+ window.activate()
246
+ else:
247
+ LaunchApplication("Visual Studio Code.app" if mac else "code")()
248
+
249
+ kt[f"{LEADER}-E"] = activate_or_launch_editor
250
+
251
+ # ==================================================================
252
+ # Keyboard macros
253
+ # ==================================================================
254
+
255
+ kt[f"{LEADER}-OpenBracket"] = ToggleRecordingKeys() # record on/off
256
+ kt[f"{LEADER}-CloseBracket"] = PlaybackRecordedKeys() # replay
257
+ # StartRecordingKeys() / StopRecordingKeys() exist too, if you would
258
+ # rather have separate keys than a toggle.
259
+
260
+ # ==================================================================
261
+ # Background work: ThreadedAction
262
+ # ==================================================================
263
+
264
+ # Anything slow (network, subprocess, sleeping) must not run inline -
265
+ # it would block the keyboard hook. ThreadedAction gives you a worker
266
+ # thread; starting() and finished() run under the engine lock, run()
267
+ # does not.
268
+ class TypeSlowly(ThreadedAction):
269
+ def __init__(self, text):
270
+ self.text = text
271
+
272
+ def starting(self):
273
+ logger.info(f"Typing {self.text!r}...")
274
+
275
+ def run(self):
276
+ import time
277
+ for char in self.text:
278
+ time.sleep(0.05)
279
+ with keymap.get_input_context() as ctx:
280
+ ctx.send_key(f"Shift-{char}" if char.isupper() else char)
281
+ return len(self.text)
282
+
283
+ def finished(self, result):
284
+ logger.info(f"Typed {result} characters.")
285
+
286
+ def __repr__(self):
287
+ return f"TypeSlowly({self.text!r})"
288
+
289
+ kt[f"{LEADER}-Y"] = TypeSlowly("keyhac")
290
+
291
+ # ==================================================================
292
+ # Multi-stroke key tables
293
+ # ==================================================================
294
+
295
+ # Press LEADER-X, then a second key. A balloon shows the table's name
296
+ # while it is armed.
297
+ kt_x = keymap.define_keytable(name="LEADER-X")
298
+ kt[f"{LEADER}-X"] = kt_x
299
+ kt_x["C"] = f"{MOD}-C"
300
+ kt_x["V"] = f"{MOD}-V"
301
+ kt_x["S"] = f"{MOD}-S"
302
+
303
+ # --- balloon messages of your own ------------------------------------
304
+ def show_balloon():
305
+ # Absent when running with --no-ui, so ask before using it.
306
+ pop = getattr(keymap, "pop_balloon", None)
307
+ if pop:
308
+ pop("hello", "Keyhac is running", 2.0)
309
+
310
+ kt[f"{LEADER}-B"] = show_balloon
311
+
312
+ # ==================================================================
313
+ # Application-specific key tables
314
+ # ==================================================================
315
+ # Tables are merged in definition order and later ones win, so anything
316
+ # below overrides the global table for the apps it matches.
317
+
318
+ # --- by application name (portable) ----------------------------------
319
+ kt_browser = keymap.define_keytable(app="chrome|Google Chrome|firefox|Safari")
320
+ kt_browser[f"{LEADER}-R"] = f"{MOD}-R" # reload
321
+
322
+ # --- by window title -------------------------------------------------
323
+ # kt_docs = keymap.define_keytable(title="*Google Docs*")
324
+
325
+ # --- by Win32 window class (Windows only) -----------------------------
326
+ if not mac:
327
+ kt_notepad = keymap.define_keytable(app="notepad", class_name="Edit")
328
+ kt_notepad[f"{LEADER}-D"] = "Home", "Shift-Down", "Shift-End", "Delete"
329
+
330
+ # --- by focus path ----------------------------------------------------
331
+ # The focus path is the control hierarchy down to the focused element -
332
+ # the AX tree on macOS, the UI Automation tree on Windows. Watch the
333
+ # console's "Focus path" field to see the live value, and use "*" freely
334
+ # to skip levels.
335
+ # macOS : /AXApplication(Xcode)/AXWindow(...)/.../AXTextArea()
336
+ # Windows : /Application(Code)/Window(...)/.../Edit(Message input)
337
+ # Note the trailing "(*)": a component is "Role(Name)", and many controls
338
+ # do carry a name, so "*/Edit()" would only match unnamed ones.
339
+ kt_textarea = keymap.define_keytable(
340
+ focus_path_pattern="*/AXTextArea(*)" if mac else "*/Edit(*)")
341
+ kt_textarea[f"{LEADER}-Slash"] = InputText("# ")
342
+
343
+ # --- by your own test -------------------------------------------------
344
+ # custom_condition_func receives the portable Focus object: app_name,
345
+ # window_title, class_name (Windows), path, and element - the focused
346
+ # element in the OS's own vocabulary.
347
+ def is_terminal(focus):
348
+ if focus.app_name in ("Terminal", "iTerm2", "WindowsTerminal", "cmd",
349
+ "powershell", "pwsh"):
350
+ return True
351
+ # Element attributes differ per OS: AX names on macOS, UI Automation
352
+ # names on Windows. An unknown name simply reads back as None.
353
+ element = focus.element
354
+ if element is None:
355
+ return False
356
+ role = element.get_attribute_value("AXRole" if mac else "ControlType")
357
+ return role in ("AXTextArea", "Document")
358
+
359
+ kt_terminal = keymap.define_keytable(custom_condition_func=is_terminal)
360
+ kt_terminal[f"{LEADER}-K"] = "Ctrl-K" # clear, rather than "Down"
361
+
362
+ # ==================================================================
363
+ # More to explore
364
+ # ==================================================================
365
+ # keymap.focus - the current Focus (app_name, window_title,
366
+ # class_name, path, element, native)
367
+ # keymap.replay_buffer - the macro buffer behind the record actions
368
+ # keymap.clipboard_history.items() / set_current(text)
369
+ # Opening a URL or a file: import subprocess and run
370
+ # subprocess.Popen(["open", url] if mac else ["cmd", "/c", "start", url])
371
+ #
372
+ # Full reference: doc/configuration.md in the Keyhac 2 source tree.