kde-which-key 0.1.0__tar.gz
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.
- kde_which_key-0.1.0/PKG-INFO +33 -0
- kde_which_key-0.1.0/README.md +18 -0
- kde_which_key-0.1.0/kde_which_key/__init__.py +0 -0
- kde_which_key-0.1.0/kde_which_key/main.py +578 -0
- kde_which_key-0.1.0/kde_which_key.egg-info/PKG-INFO +33 -0
- kde_which_key-0.1.0/kde_which_key.egg-info/SOURCES.txt +9 -0
- kde_which_key-0.1.0/kde_which_key.egg-info/dependency_links.txt +1 -0
- kde_which_key-0.1.0/kde_which_key.egg-info/entry_points.txt +2 -0
- kde_which_key-0.1.0/kde_which_key.egg-info/top_level.txt +1 -0
- kde_which_key-0.1.0/pyproject.toml +27 -0
- kde_which_key-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kde-which-key
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive keyboard shortcut browser and launcher for KDE
|
|
5
|
+
Author: readwithai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/readwithai/kde-which-key
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Desktop Environment :: K Desktop Environment (KDE)
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# kde-which-key
|
|
17
|
+
Query kde shortcuts. Inspired by `which-key in emacs`.
|
|
18
|
+
|
|
19
|
+
AI-generated and unreviewed.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
`pipx install kde-which-key`
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
`kde-which` shows a popup for filtering keys. You can use `?` to fuzzy search for keybindings.
|
|
26
|
+
|
|
27
|
+
## Related tools
|
|
28
|
+
You may also like my tool kde-shortcuts which can add and remove shortcuts.
|
|
29
|
+
|
|
30
|
+
## About
|
|
31
|
+
I am @readwithai. I make tools for reading and agency with and without AI and Obsidian. I also produce a stream of small tools like this as part of my work. If this tool sounds interesting you might like to [follow me on github](https://github.com/talwrii).
|
|
32
|
+
|
|
33
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# kde-which-key
|
|
2
|
+
Query kde shortcuts. Inspired by `which-key in emacs`.
|
|
3
|
+
|
|
4
|
+
AI-generated and unreviewed.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
`pipx install kde-which-key`
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
`kde-which` shows a popup for filtering keys. You can use `?` to fuzzy search for keybindings.
|
|
11
|
+
|
|
12
|
+
## Related tools
|
|
13
|
+
You may also like my tool kde-shortcuts which can add and remove shortcuts.
|
|
14
|
+
|
|
15
|
+
## About
|
|
16
|
+
I am @readwithai. I make tools for reading and agency with and without AI and Obsidian. I also produce a stream of small tools like this as part of my work. If this tool sounds interesting you might like to [follow me on github](https://github.com/talwrii).
|
|
17
|
+
|
|
18
|
+
|
|
File without changes
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""kde-which-key: Interactive shortcut browser and launcher for KDE."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "kglobalshortcutsrc"
|
|
13
|
+
DESKTOP_DIR = Path.home() / ".local" / "share" / "applications"
|
|
14
|
+
|
|
15
|
+
TKINTER_TO_KDE = {
|
|
16
|
+
"Control_L": "Ctrl", "Control_R": "Ctrl",
|
|
17
|
+
"Alt_L": "Alt", "Alt_R": "Alt",
|
|
18
|
+
"Super_L": "Meta", "Super_R": "Meta",
|
|
19
|
+
"Shift_L": "Shift", "Shift_R": "Shift",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
MODIFIER_KEYSYMS = {
|
|
23
|
+
"Control_L", "Control_R", "Alt_L", "Alt_R",
|
|
24
|
+
"Super_L", "Super_R", "Shift_L", "Shift_R",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
MODIFIER_ORDER = ["Meta", "Ctrl", "Alt", "Shift"]
|
|
28
|
+
|
|
29
|
+
# Tri-state: None = any, True = must have, False = must not have
|
|
30
|
+
TRISTATE_CYCLE = {None: True, True: False, False: None}
|
|
31
|
+
TRISTATE_LABEL = {None: "·", True: "✓", False: "✗"}
|
|
32
|
+
TRISTATE_FG = {None: "#6c7086", True: "#a6e3a1", False: "#f38ba8"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Shortcut:
|
|
37
|
+
group: str
|
|
38
|
+
key: str
|
|
39
|
+
binding: str
|
|
40
|
+
description: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def display_name(self) -> str:
|
|
44
|
+
if self.description and self.description != self.key:
|
|
45
|
+
return f"{self.description}"
|
|
46
|
+
return self.key
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def bindings(self) -> list[str]:
|
|
50
|
+
"""Split multi-bound shortcuts."""
|
|
51
|
+
return [b.strip() for b in self.binding.split("\\t")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_shortcuts(config_path: Optional[Path] = None) -> list[Shortcut]:
|
|
55
|
+
"""Load all active shortcuts from kglobalshortcutsrc."""
|
|
56
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
57
|
+
parser = configparser.RawConfigParser()
|
|
58
|
+
parser.optionxform = str
|
|
59
|
+
if path.exists():
|
|
60
|
+
parser.read(str(path))
|
|
61
|
+
|
|
62
|
+
shortcuts = []
|
|
63
|
+
for section in parser.sections():
|
|
64
|
+
for key, value in parser.items(section):
|
|
65
|
+
if key == "_k_friendly_name":
|
|
66
|
+
continue
|
|
67
|
+
parts = value.split(",", 2)
|
|
68
|
+
if len(parts) == 3:
|
|
69
|
+
active, _default, description = parts
|
|
70
|
+
elif len(parts) == 2:
|
|
71
|
+
active, _default = parts
|
|
72
|
+
description = ""
|
|
73
|
+
else:
|
|
74
|
+
active = parts[0] if parts else ""
|
|
75
|
+
description = ""
|
|
76
|
+
|
|
77
|
+
active = active.strip()
|
|
78
|
+
if active and active.lower() not in ("", "none"):
|
|
79
|
+
shortcuts.append(Shortcut(
|
|
80
|
+
group=section,
|
|
81
|
+
key=key,
|
|
82
|
+
binding=active,
|
|
83
|
+
description=description.strip(),
|
|
84
|
+
))
|
|
85
|
+
return shortcuts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def invoke_shortcut(sc: Shortcut):
|
|
89
|
+
"""Trigger a shortcut action via dbus or by running the desktop Exec."""
|
|
90
|
+
if sc.group.endswith(".desktop"):
|
|
91
|
+
desktop_path = DESKTOP_DIR / sc.group
|
|
92
|
+
if desktop_path.exists():
|
|
93
|
+
for line in desktop_path.read_text().splitlines():
|
|
94
|
+
if line.startswith("Exec="):
|
|
95
|
+
cmd = line[5:].strip()
|
|
96
|
+
for code in ["%u", "%U", "%f", "%F", "%i", "%c", "%k"]:
|
|
97
|
+
cmd = cmd.replace(code, "")
|
|
98
|
+
cmd = cmd.strip()
|
|
99
|
+
subprocess.Popen(cmd, shell=True)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
component = sc.group.removesuffix(".desktop")
|
|
103
|
+
_invoke_via_dbus(component, sc.key)
|
|
104
|
+
else:
|
|
105
|
+
_invoke_via_dbus(sc.group, sc.key)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _invoke_via_dbus(component: str, action: str):
|
|
109
|
+
"""Invoke a shortcut action via kglobalaccel dbus."""
|
|
110
|
+
try:
|
|
111
|
+
subprocess.Popen([
|
|
112
|
+
"qdbus", "org.kde.kglobalaccel",
|
|
113
|
+
f"/component/{component}",
|
|
114
|
+
"org.kde.kglobalaccel.Component.invokeShortcut",
|
|
115
|
+
action,
|
|
116
|
+
])
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fuzzy_match(query: str, text: str) -> tuple[bool, int]:
|
|
122
|
+
"""Simple fuzzy match. Returns (matched, score). Lower score = better."""
|
|
123
|
+
query_lower = query.lower()
|
|
124
|
+
text_lower = text.lower()
|
|
125
|
+
|
|
126
|
+
if query_lower in text_lower:
|
|
127
|
+
return True, text_lower.index(query_lower)
|
|
128
|
+
|
|
129
|
+
qi = 0
|
|
130
|
+
score = 0
|
|
131
|
+
last_pos = -1
|
|
132
|
+
for ti, ch in enumerate(text_lower):
|
|
133
|
+
if qi < len(query_lower) and ch == query_lower[qi]:
|
|
134
|
+
gap = ti - last_pos - 1
|
|
135
|
+
score += gap
|
|
136
|
+
last_pos = ti
|
|
137
|
+
qi += 1
|
|
138
|
+
|
|
139
|
+
if qi == len(query_lower):
|
|
140
|
+
return True, score + 100
|
|
141
|
+
return False, 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_binding_parts(binding: str) -> tuple[set[str], str]:
|
|
145
|
+
"""Split a binding like 'Meta+Shift+A' into (modifiers, key)."""
|
|
146
|
+
parts = binding.split("+")
|
|
147
|
+
mods = set()
|
|
148
|
+
key = ""
|
|
149
|
+
for p in parts:
|
|
150
|
+
if p in MODIFIER_ORDER:
|
|
151
|
+
mods.add(p)
|
|
152
|
+
else:
|
|
153
|
+
key = p
|
|
154
|
+
return mods, key
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _block_global_shortcuts(block: bool):
|
|
158
|
+
"""Tell kglobalaccel to block/unblock global shortcuts."""
|
|
159
|
+
try:
|
|
160
|
+
subprocess.run(
|
|
161
|
+
["qdbus", "org.kde.kglobalaccel", "/kglobalaccel",
|
|
162
|
+
"blockGlobalShortcuts", "true" if block else "false"],
|
|
163
|
+
capture_output=True, timeout=5,
|
|
164
|
+
)
|
|
165
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class WhichKeyApp:
|
|
170
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
171
|
+
self.shortcuts = load_shortcuts(config_path)
|
|
172
|
+
self.filtered: list[Shortcut] = list(self.shortcuts)
|
|
173
|
+
self.selected_index = 0
|
|
174
|
+
|
|
175
|
+
# Key filter state
|
|
176
|
+
self.modifiers_held: set[str] = set()
|
|
177
|
+
self.key_filter_key: str = ""
|
|
178
|
+
|
|
179
|
+
# Tri-state modifier filters: None=any, True=must have, False=must not have
|
|
180
|
+
self.mod_filter: dict[str, Optional[bool]] = {m: None for m in MODIFIER_ORDER}
|
|
181
|
+
|
|
182
|
+
# Search mode
|
|
183
|
+
self.search_mode = False
|
|
184
|
+
self.search_query = ""
|
|
185
|
+
|
|
186
|
+
self._build_ui()
|
|
187
|
+
|
|
188
|
+
def _build_ui(self):
|
|
189
|
+
import tkinter as tk
|
|
190
|
+
import tkinter.font as tkfont
|
|
191
|
+
self._tk = tk
|
|
192
|
+
|
|
193
|
+
self.root = tk.Tk()
|
|
194
|
+
self.root.title("kde-which-key")
|
|
195
|
+
self.root.attributes("-topmost", True)
|
|
196
|
+
|
|
197
|
+
width, height = 700, 540
|
|
198
|
+
self.root.geometry(f"{width}x{height}")
|
|
199
|
+
self.root.update_idletasks()
|
|
200
|
+
x = (self.root.winfo_screenwidth() - width) // 2
|
|
201
|
+
y = (self.root.winfo_screenheight() - height) // 2
|
|
202
|
+
self.root.geometry(f"{width}x{height}+{x}+{y}")
|
|
203
|
+
|
|
204
|
+
self.root.configure(bg="#1e1e2e")
|
|
205
|
+
|
|
206
|
+
self.font_main = tkfont.Font(family="monospace", size=11)
|
|
207
|
+
self.font_binding = tkfont.Font(family="monospace", size=11, weight="bold")
|
|
208
|
+
self.font_status = tkfont.Font(family="sans-serif", size=10)
|
|
209
|
+
self.font_search = tkfont.Font(family="monospace", size=14)
|
|
210
|
+
self.font_btn = tkfont.Font(family="monospace", size=10, weight="bold")
|
|
211
|
+
|
|
212
|
+
# Status bar
|
|
213
|
+
self.status_frame = tk.Frame(self.root, bg="#313244", height=40)
|
|
214
|
+
self.status_frame.pack(fill="x", padx=8, pady=(8, 4))
|
|
215
|
+
self.status_frame.pack_propagate(False)
|
|
216
|
+
|
|
217
|
+
self.status_label = tk.Label(
|
|
218
|
+
self.status_frame,
|
|
219
|
+
text="Press keys to filter | ? = search | Backspace = clear | Esc = quit",
|
|
220
|
+
font=self.font_status, fg="#a6adc8", bg="#313244", anchor="w",
|
|
221
|
+
)
|
|
222
|
+
self.status_label.pack(fill="both", expand=True, padx=10)
|
|
223
|
+
|
|
224
|
+
self.search_entry = tk.Entry(
|
|
225
|
+
self.status_frame, font=self.font_search,
|
|
226
|
+
fg="#cdd6f4", bg="#45475a", insertbackground="#cdd6f4",
|
|
227
|
+
relief="flat", borderwidth=0,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Modifier toggle buttons
|
|
231
|
+
self.btn_frame = tk.Frame(self.root, bg="#1e1e2e")
|
|
232
|
+
self.btn_frame.pack(fill="x", padx=8, pady=(0, 4))
|
|
233
|
+
|
|
234
|
+
self.mod_buttons: dict[str, tk.Button] = {}
|
|
235
|
+
for mod in MODIFIER_ORDER:
|
|
236
|
+
btn = tk.Button(
|
|
237
|
+
self.btn_frame,
|
|
238
|
+
text=f"{mod} {TRISTATE_LABEL[None]}",
|
|
239
|
+
font=self.font_btn,
|
|
240
|
+
fg=TRISTATE_FG[None], bg="#313244",
|
|
241
|
+
activeforeground="#cdd6f4", activebackground="#45475a",
|
|
242
|
+
relief="flat", borderwidth=0, padx=12, pady=4,
|
|
243
|
+
command=lambda m=mod: self._toggle_mod(m),
|
|
244
|
+
)
|
|
245
|
+
btn.pack(side="left", padx=4)
|
|
246
|
+
self.mod_buttons[mod] = btn
|
|
247
|
+
|
|
248
|
+
# Key filter label
|
|
249
|
+
self.key_label = tk.Label(
|
|
250
|
+
self.btn_frame, text="", font=self.font_btn,
|
|
251
|
+
fg="#f5c2e7", bg="#1e1e2e",
|
|
252
|
+
)
|
|
253
|
+
self.key_label.pack(side="left", padx=(12, 0))
|
|
254
|
+
|
|
255
|
+
# Match count
|
|
256
|
+
self.count_label = tk.Label(
|
|
257
|
+
self.btn_frame, text="", font=self.font_status,
|
|
258
|
+
fg="#a6adc8", bg="#1e1e2e",
|
|
259
|
+
)
|
|
260
|
+
self.count_label.pack(side="right", padx=8)
|
|
261
|
+
|
|
262
|
+
# List area
|
|
263
|
+
self.list_frame = tk.Frame(self.root, bg="#1e1e2e")
|
|
264
|
+
self.list_frame.pack(fill="both", expand=True, padx=8, pady=(0, 8))
|
|
265
|
+
|
|
266
|
+
self.canvas = tk.Canvas(self.list_frame, bg="#1e1e2e", highlightthickness=0)
|
|
267
|
+
self.scrollbar = tk.Scrollbar(self.list_frame, orient="vertical", command=self.canvas.yview)
|
|
268
|
+
self.inner_frame = tk.Frame(self.canvas, bg="#1e1e2e")
|
|
269
|
+
|
|
270
|
+
self.inner_frame.bind("<Configure>",
|
|
271
|
+
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
|
|
272
|
+
self.canvas.create_window((0, 0), window=self.inner_frame, anchor="nw")
|
|
273
|
+
self.canvas.configure(yscrollcommand=self.scrollbar.set)
|
|
274
|
+
|
|
275
|
+
self.canvas.pack(side="left", fill="both", expand=True)
|
|
276
|
+
self.scrollbar.pack(side="right", fill="y")
|
|
277
|
+
|
|
278
|
+
# Key bindings
|
|
279
|
+
self.root.bind("<KeyPress>", self._on_key_press)
|
|
280
|
+
self.root.bind("<KeyRelease>", self._on_key_release)
|
|
281
|
+
self.root.bind("<Escape>", self._on_escape)
|
|
282
|
+
self.root.bind("<Return>", self._on_enter)
|
|
283
|
+
self.root.bind("<Up>", self._on_arrow_up)
|
|
284
|
+
self.root.bind("<Down>", self._on_arrow_down)
|
|
285
|
+
self.root.bind("<BackSpace>", self._on_backspace)
|
|
286
|
+
self.canvas.bind_all("<Button-4>",
|
|
287
|
+
lambda e: self.canvas.yview_scroll(-3, "units"))
|
|
288
|
+
self.canvas.bind_all("<Button-5>",
|
|
289
|
+
lambda e: self.canvas.yview_scroll(3, "units"))
|
|
290
|
+
|
|
291
|
+
self._update_list()
|
|
292
|
+
|
|
293
|
+
def _toggle_mod(self, mod: str):
|
|
294
|
+
"""Cycle modifier filter: any → on → off → any."""
|
|
295
|
+
self.mod_filter[mod] = TRISTATE_CYCLE[self.mod_filter[mod]]
|
|
296
|
+
self._update_mod_buttons()
|
|
297
|
+
self._apply_key_filter()
|
|
298
|
+
|
|
299
|
+
def _update_mod_buttons(self):
|
|
300
|
+
for mod, btn in self.mod_buttons.items():
|
|
301
|
+
state = self.mod_filter[mod]
|
|
302
|
+
btn.config(
|
|
303
|
+
text=f"{mod} {TRISTATE_LABEL[state]}",
|
|
304
|
+
fg=TRISTATE_FG[state],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _on_key_press(self, event):
|
|
308
|
+
if self.search_mode:
|
|
309
|
+
self._handle_search_key(event)
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
keysym = event.keysym
|
|
313
|
+
|
|
314
|
+
# ? enters search mode
|
|
315
|
+
if keysym == "question" or (keysym == "slash" and event.state & 1):
|
|
316
|
+
self._enter_search_mode()
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
if keysym in MODIFIER_KEYSYMS:
|
|
320
|
+
kde_name = TKINTER_TO_KDE.get(keysym, keysym)
|
|
321
|
+
self.modifiers_held.add(kde_name)
|
|
322
|
+
# Pressing a modifier sets it to "must have"
|
|
323
|
+
self.mod_filter[kde_name] = True
|
|
324
|
+
self._update_mod_buttons()
|
|
325
|
+
self._apply_key_filter()
|
|
326
|
+
else:
|
|
327
|
+
kde_key = keysym
|
|
328
|
+
if len(keysym) == 1:
|
|
329
|
+
kde_key = keysym.upper()
|
|
330
|
+
|
|
331
|
+
self.key_filter_key = kde_key
|
|
332
|
+
self._apply_key_filter()
|
|
333
|
+
|
|
334
|
+
def _on_key_release(self, event):
|
|
335
|
+
if self.search_mode:
|
|
336
|
+
return
|
|
337
|
+
keysym = event.keysym
|
|
338
|
+
if keysym in MODIFIER_KEYSYMS:
|
|
339
|
+
kde_name = TKINTER_TO_KDE.get(keysym, keysym)
|
|
340
|
+
self.modifiers_held.discard(kde_name)
|
|
341
|
+
|
|
342
|
+
def _on_escape(self, event):
|
|
343
|
+
if self.search_mode:
|
|
344
|
+
self._exit_search_mode()
|
|
345
|
+
elif self._has_filter():
|
|
346
|
+
self._reset_filter()
|
|
347
|
+
else:
|
|
348
|
+
self.root.destroy()
|
|
349
|
+
|
|
350
|
+
def _on_backspace(self, event):
|
|
351
|
+
if self.search_mode:
|
|
352
|
+
return
|
|
353
|
+
self._reset_filter()
|
|
354
|
+
|
|
355
|
+
def _on_enter(self, event):
|
|
356
|
+
if self.filtered and 0 <= self.selected_index < len(self.filtered):
|
|
357
|
+
sc = self.filtered[self.selected_index]
|
|
358
|
+
self.root.destroy()
|
|
359
|
+
invoke_shortcut(sc)
|
|
360
|
+
|
|
361
|
+
def _on_arrow_up(self, event):
|
|
362
|
+
if self.selected_index > 0:
|
|
363
|
+
self.selected_index -= 1
|
|
364
|
+
self._update_list()
|
|
365
|
+
self._ensure_visible()
|
|
366
|
+
|
|
367
|
+
def _on_arrow_down(self, event):
|
|
368
|
+
if self.selected_index < len(self.filtered) - 1:
|
|
369
|
+
self.selected_index += 1
|
|
370
|
+
self._update_list()
|
|
371
|
+
self._ensure_visible()
|
|
372
|
+
|
|
373
|
+
def _enter_search_mode(self):
|
|
374
|
+
self.search_mode = True
|
|
375
|
+
self.search_query = ""
|
|
376
|
+
self.status_label.pack_forget()
|
|
377
|
+
self.search_entry.pack(fill="both", expand=True, padx=10, pady=5)
|
|
378
|
+
self.search_entry.focus_set()
|
|
379
|
+
self.search_entry.delete(0, self._tk.END)
|
|
380
|
+
self.search_entry.bind("<Key>", self._on_search_entry_key)
|
|
381
|
+
self._apply_search_filter()
|
|
382
|
+
|
|
383
|
+
def _exit_search_mode(self):
|
|
384
|
+
self.search_mode = False
|
|
385
|
+
self.search_query = ""
|
|
386
|
+
self.search_entry.pack_forget()
|
|
387
|
+
self.status_label.pack(fill="both", expand=True, padx=10)
|
|
388
|
+
self._reset_filter()
|
|
389
|
+
self.root.focus_set()
|
|
390
|
+
|
|
391
|
+
def _handle_search_key(self, event):
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
def _on_search_entry_key(self, event):
|
|
395
|
+
if event.keysym == "Escape":
|
|
396
|
+
self._exit_search_mode()
|
|
397
|
+
return "break"
|
|
398
|
+
if event.keysym == "Return":
|
|
399
|
+
self._on_enter(event)
|
|
400
|
+
return "break"
|
|
401
|
+
if event.keysym == "Up":
|
|
402
|
+
self._on_arrow_up(event)
|
|
403
|
+
return "break"
|
|
404
|
+
if event.keysym == "Down":
|
|
405
|
+
self._on_arrow_down(event)
|
|
406
|
+
return "break"
|
|
407
|
+
|
|
408
|
+
self.root.after(1, self._apply_search_filter)
|
|
409
|
+
|
|
410
|
+
def _has_filter(self) -> bool:
|
|
411
|
+
return (
|
|
412
|
+
self.key_filter_key != ""
|
|
413
|
+
or any(v is not None for v in self.mod_filter.values())
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _apply_key_filter(self):
|
|
417
|
+
"""Filter shortcuts by modifier tri-state and key."""
|
|
418
|
+
self.filtered = []
|
|
419
|
+
|
|
420
|
+
for sc in self.shortcuts:
|
|
421
|
+
for binding in sc.bindings:
|
|
422
|
+
bind_mods, bind_key = parse_binding_parts(binding)
|
|
423
|
+
|
|
424
|
+
match = True
|
|
425
|
+
for mod in MODIFIER_ORDER:
|
|
426
|
+
want = self.mod_filter[mod]
|
|
427
|
+
has = mod in bind_mods
|
|
428
|
+
if want is True and not has:
|
|
429
|
+
match = False
|
|
430
|
+
break
|
|
431
|
+
if want is False and has:
|
|
432
|
+
match = False
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
if not match:
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
if self.key_filter_key:
|
|
439
|
+
if bind_key.lower() != self.key_filter_key.lower():
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
self.filtered.append(sc)
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
self.selected_index = 0
|
|
446
|
+
self._update_key_label()
|
|
447
|
+
self._update_list()
|
|
448
|
+
|
|
449
|
+
def _apply_search_filter(self):
|
|
450
|
+
"""Filter shortcuts by fuzzy search query."""
|
|
451
|
+
query = self.search_entry.get().strip()
|
|
452
|
+
if not query:
|
|
453
|
+
self.filtered = list(self.shortcuts)
|
|
454
|
+
self.selected_index = 0
|
|
455
|
+
self._update_list()
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
scored = []
|
|
459
|
+
for sc in self.shortcuts:
|
|
460
|
+
best_match = False
|
|
461
|
+
best_score = 99999
|
|
462
|
+
for text in [sc.display_name, sc.key, sc.group, sc.binding]:
|
|
463
|
+
matched, score = fuzzy_match(query, text)
|
|
464
|
+
if matched and score < best_score:
|
|
465
|
+
best_match = True
|
|
466
|
+
best_score = score
|
|
467
|
+
|
|
468
|
+
if best_match:
|
|
469
|
+
scored.append((best_score, sc))
|
|
470
|
+
|
|
471
|
+
scored.sort(key=lambda x: x[0])
|
|
472
|
+
self.filtered = [sc for _, sc in scored]
|
|
473
|
+
self.selected_index = 0
|
|
474
|
+
self._update_list()
|
|
475
|
+
|
|
476
|
+
def _reset_filter(self):
|
|
477
|
+
self.mod_filter = {m: None for m in MODIFIER_ORDER}
|
|
478
|
+
self.key_filter_key = ""
|
|
479
|
+
self.filtered = list(self.shortcuts)
|
|
480
|
+
self.selected_index = 0
|
|
481
|
+
self._update_mod_buttons()
|
|
482
|
+
self._update_key_label()
|
|
483
|
+
self._update_list()
|
|
484
|
+
|
|
485
|
+
def _update_key_label(self):
|
|
486
|
+
if self.key_filter_key:
|
|
487
|
+
self.key_label.config(text=f"+ {self.key_filter_key}")
|
|
488
|
+
else:
|
|
489
|
+
self.key_label.config(text="")
|
|
490
|
+
self.count_label.config(text=f"{len(self.filtered)} matches")
|
|
491
|
+
|
|
492
|
+
def _update_list(self):
|
|
493
|
+
"""Redraw the shortcut list."""
|
|
494
|
+
for widget in self.inner_frame.winfo_children():
|
|
495
|
+
widget.destroy()
|
|
496
|
+
|
|
497
|
+
self.count_label.config(text=f"{len(self.filtered)} matches")
|
|
498
|
+
|
|
499
|
+
if not self.filtered:
|
|
500
|
+
label = self._tk.Label(
|
|
501
|
+
self.inner_frame, text="No matching shortcuts",
|
|
502
|
+
font=self.font_main, fg="#6c7086", bg="#1e1e2e",
|
|
503
|
+
)
|
|
504
|
+
label.pack(pady=20)
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
for i, sc in enumerate(self.filtered):
|
|
508
|
+
is_selected = (i == self.selected_index)
|
|
509
|
+
bg = "#45475a" if is_selected else "#1e1e2e"
|
|
510
|
+
fg_desc = "#cdd6f4" if is_selected else "#bac2de"
|
|
511
|
+
fg_bind = "#f5c2e7" if is_selected else "#a6adc8"
|
|
512
|
+
|
|
513
|
+
row = self._tk.Frame(self.inner_frame, bg=bg)
|
|
514
|
+
row.pack(fill="x", padx=4, pady=1)
|
|
515
|
+
|
|
516
|
+
desc_label = self._tk.Label(
|
|
517
|
+
row, text=f" {sc.display_name}",
|
|
518
|
+
font=self.font_main, fg=fg_desc, bg=bg,
|
|
519
|
+
anchor="w", width=45,
|
|
520
|
+
)
|
|
521
|
+
desc_label.pack(side="left", fill="x", expand=True)
|
|
522
|
+
|
|
523
|
+
bind_label = self._tk.Label(
|
|
524
|
+
row, text=f"{sc.binding} ",
|
|
525
|
+
font=self.font_binding, fg=fg_bind, bg=bg,
|
|
526
|
+
anchor="e",
|
|
527
|
+
)
|
|
528
|
+
bind_label.pack(side="right")
|
|
529
|
+
|
|
530
|
+
for widget in [row, desc_label, bind_label]:
|
|
531
|
+
widget.bind("<Button-1>", lambda e, idx=i: self._click_item(idx))
|
|
532
|
+
|
|
533
|
+
def _click_item(self, index):
|
|
534
|
+
self.selected_index = index
|
|
535
|
+
self._update_list()
|
|
536
|
+
sc = self.filtered[index]
|
|
537
|
+
self.root.destroy()
|
|
538
|
+
invoke_shortcut(sc)
|
|
539
|
+
|
|
540
|
+
def _ensure_visible(self):
|
|
541
|
+
"""Scroll to keep selected item visible."""
|
|
542
|
+
self.root.update_idletasks()
|
|
543
|
+
children = self.inner_frame.winfo_children()
|
|
544
|
+
if 0 <= self.selected_index < len(children):
|
|
545
|
+
widget = children[self.selected_index]
|
|
546
|
+
y = widget.winfo_y()
|
|
547
|
+
h = widget.winfo_height()
|
|
548
|
+
canvas_h = self.canvas.winfo_height()
|
|
549
|
+
total = self.inner_frame.winfo_height()
|
|
550
|
+
if total > 0:
|
|
551
|
+
top = self.canvas.yview()[0] * total
|
|
552
|
+
bottom = top + canvas_h
|
|
553
|
+
if y < top:
|
|
554
|
+
self.canvas.yview_moveto(y / total)
|
|
555
|
+
elif y + h > bottom:
|
|
556
|
+
self.canvas.yview_moveto((y + h - canvas_h) / total)
|
|
557
|
+
|
|
558
|
+
def run(self):
|
|
559
|
+
_block_global_shortcuts(True)
|
|
560
|
+
try:
|
|
561
|
+
self.root.mainloop()
|
|
562
|
+
finally:
|
|
563
|
+
_block_global_shortcuts(False)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def main():
|
|
567
|
+
import argparse
|
|
568
|
+
parser = argparse.ArgumentParser(description="Interactive KDE shortcut browser")
|
|
569
|
+
parser.add_argument("--config", default=None, help="Path to kglobalshortcutsrc")
|
|
570
|
+
args = parser.parse_args()
|
|
571
|
+
|
|
572
|
+
config_path = Path(args.config) if args.config else None
|
|
573
|
+
app = WhichKeyApp(config_path)
|
|
574
|
+
app.run()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
if __name__ == "__main__":
|
|
578
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kde-which-key
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive keyboard shortcut browser and launcher for KDE
|
|
5
|
+
Author: readwithai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/readwithai/kde-which-key
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Desktop Environment :: K Desktop Environment (KDE)
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# kde-which-key
|
|
17
|
+
Query kde shortcuts. Inspired by `which-key in emacs`.
|
|
18
|
+
|
|
19
|
+
AI-generated and unreviewed.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
`pipx install kde-which-key`
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
`kde-which` shows a popup for filtering keys. You can use `?` to fuzzy search for keybindings.
|
|
26
|
+
|
|
27
|
+
## Related tools
|
|
28
|
+
You may also like my tool kde-shortcuts which can add and remove shortcuts.
|
|
29
|
+
|
|
30
|
+
## About
|
|
31
|
+
I am @readwithai. I make tools for reading and agency with and without AI and Obsidian. I also produce a stream of small tools like this as part of my work. If this tool sounds interesting you might like to [follow me on github](https://github.com/talwrii).
|
|
32
|
+
|
|
33
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
kde_which_key/__init__.py
|
|
4
|
+
kde_which_key/main.py
|
|
5
|
+
kde_which_key.egg-info/PKG-INFO
|
|
6
|
+
kde_which_key.egg-info/SOURCES.txt
|
|
7
|
+
kde_which_key.egg-info/dependency_links.txt
|
|
8
|
+
kde_which_key.egg-info/entry_points.txt
|
|
9
|
+
kde_which_key.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kde_which_key
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kde-which-key"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Interactive keyboard shortcut browser and launcher for KDE"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "readwithai" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Desktop Environment :: K Desktop Environment (KDE)",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
kde-which = "kde_which_key.main:main"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/readwithai/kde-which-key"
|