plotruler 0.1.3__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.
- plotruler/__init__.py +3 -0
- plotruler/__main__.py +98 -0
- plotruler/core.py +371 -0
- plotruler/format.py +262 -0
- plotruler/hotkey.py +240 -0
- plotruler/overlay.py +1135 -0
- plotruler/settings.py +130 -0
- plotruler/storage.py +144 -0
- plotruler/titlebar.py +226 -0
- plotruler/tray.py +179 -0
- plotruler/win_hittest.py +292 -0
- plotruler-0.1.3.dist-info/METADATA +206 -0
- plotruler-0.1.3.dist-info/RECORD +15 -0
- plotruler-0.1.3.dist-info/WHEEL +4 -0
- plotruler-0.1.3.dist-info/licenses/LICENSE +22 -0
plotruler/format.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Number-format options for the readout.
|
|
2
|
+
|
|
3
|
+
This module is free of any Qt imports so it stays unit-testable and
|
|
4
|
+
portable. The readout can show a value several ways: plain decimals,
|
|
5
|
+
scientific notation (m x 10^n), engineering notation (exponent a
|
|
6
|
+
multiple of 3), E notation (ASCII, the computer friendly form), or SI
|
|
7
|
+
prefixes (12.3 k). "auto" shows plain for everyday magnitudes and
|
|
8
|
+
switches to scientific only for very large or very small values.
|
|
9
|
+
|
|
10
|
+
precision: a linear axis carries positional precision (how many decimal
|
|
11
|
+
places are meaningful); a log axis carries relative precision (how many
|
|
12
|
+
significant figures). The axis computes both from a pixel-error model and
|
|
13
|
+
hands them in, so this module never has to know about the calibration.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from math import floor, isfinite, log10
|
|
17
|
+
|
|
18
|
+
# The format choices, ordered for the tray menu and the number-key
|
|
19
|
+
# shortcuts. These strings are the persistent config keys.
|
|
20
|
+
AUTO = "auto"
|
|
21
|
+
PLAIN = "plain"
|
|
22
|
+
SCIENTIFIC = "scientific"
|
|
23
|
+
ENGINEERING = "engineering"
|
|
24
|
+
E = "e"
|
|
25
|
+
SI = "si"
|
|
26
|
+
|
|
27
|
+
OPTIONS = (AUTO, PLAIN, SCIENTIFIC, ENGINEERING, E, SI)
|
|
28
|
+
|
|
29
|
+
NAMES = {
|
|
30
|
+
AUTO: "Auto",
|
|
31
|
+
PLAIN: "Plain",
|
|
32
|
+
SCIENTIFIC: "Scientific",
|
|
33
|
+
ENGINEERING: "Engineering",
|
|
34
|
+
E: "E notation",
|
|
35
|
+
SI: "SI prefixes",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# A value at or above this magnitude (or below its reciprocal) is "huge"
|
|
39
|
+
# (or "tiny") enough that auto switches to scientific notation.
|
|
40
|
+
_HIGH = 1e6
|
|
41
|
+
_LOW = 1e-4
|
|
42
|
+
|
|
43
|
+
# Cap on significant figures for the exponent styles when the precision
|
|
44
|
+
# comes from a linear axis's positional model (see render()).
|
|
45
|
+
_MAX_SIG_FIGS = 3
|
|
46
|
+
|
|
47
|
+
# ASCII superscript digits and minus, used to render the exponent in the
|
|
48
|
+
# "x 10^n" styles. Qt renders these as real superscripts.
|
|
49
|
+
_SUPERSCRIPT = {
|
|
50
|
+
"0": "\u2070",
|
|
51
|
+
"1": "\u00b9",
|
|
52
|
+
"2": "\u00b2",
|
|
53
|
+
"3": "\u00b3",
|
|
54
|
+
"4": "\u2074",
|
|
55
|
+
"5": "\u2075",
|
|
56
|
+
"6": "\u2076",
|
|
57
|
+
"7": "\u2077",
|
|
58
|
+
"8": "\u2078",
|
|
59
|
+
"9": "\u2079",
|
|
60
|
+
"-": "\u207b",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# SI prefix symbols keyed by the exponent they represent (a multiple of 3).
|
|
64
|
+
_SI_PREFIXES = {
|
|
65
|
+
30: "Q",
|
|
66
|
+
27: "R",
|
|
67
|
+
24: "Y",
|
|
68
|
+
21: "Z",
|
|
69
|
+
18: "E",
|
|
70
|
+
15: "P",
|
|
71
|
+
12: "T",
|
|
72
|
+
9: "G",
|
|
73
|
+
6: "M",
|
|
74
|
+
3: "k",
|
|
75
|
+
0: "",
|
|
76
|
+
-3: "m",
|
|
77
|
+
-6: "\u00b5", # micro sign
|
|
78
|
+
-9: "n",
|
|
79
|
+
-12: "p",
|
|
80
|
+
-15: "f",
|
|
81
|
+
-18: "a",
|
|
82
|
+
-21: "z",
|
|
83
|
+
-24: "y",
|
|
84
|
+
-27: "r",
|
|
85
|
+
-30: "q",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_valid(fmt):
|
|
90
|
+
"""True if fmt is one of the known format keys."""
|
|
91
|
+
return fmt in OPTIONS
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def superscript(exponent):
|
|
95
|
+
"""Return an integer exponent as superscript digits, e.g. -3 -> ⁻³."""
|
|
96
|
+
sign = "" if exponent >= 0 else "\u207b"
|
|
97
|
+
digits = "".join(_SUPERSCRIPT[d] for d in str(abs(exponent)))
|
|
98
|
+
return sign + digits
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _times_ten(exponent):
|
|
102
|
+
"""Render ' x 10^exponent' using the Unicode superscript form."""
|
|
103
|
+
return " \u00d7 10" + superscript(exponent)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sig_figs_for_decimals(value, decimals):
|
|
107
|
+
"""Significant figures of a value rounded to `decimals` places.
|
|
108
|
+
|
|
109
|
+
The number of significant figures is the span from the most
|
|
110
|
+
significant digit to the least significant (which sits at the
|
|
111
|
+
`decimals` place). This lets a positional-precision axis (linear)
|
|
112
|
+
report equivalent significant figures for the exponent styles.
|
|
113
|
+
"""
|
|
114
|
+
if decimals is None:
|
|
115
|
+
return None
|
|
116
|
+
if value == 0:
|
|
117
|
+
return 1
|
|
118
|
+
rounded = round(value, decimals)
|
|
119
|
+
if rounded == 0:
|
|
120
|
+
return 1
|
|
121
|
+
most_sig = floor(log10(abs(rounded)))
|
|
122
|
+
return max(1, min(15, most_sig + decimals + 1))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sig(value, sig_figs):
|
|
126
|
+
"""Round value to a number of significant figures as a plain decimal.
|
|
127
|
+
|
|
128
|
+
This must never resort to the 'g' format specifier: for a mantissa
|
|
129
|
+
like 490 with 2 significant figures, '%.2g' yields '4.9e+02', which
|
|
130
|
+
leaks scientific notation into SI prefixes or engineering mantissas.
|
|
131
|
+
Instead, compute how many decimal places a value rounded to sig_figs
|
|
132
|
+
needs and render with a plain fixed-point format.
|
|
133
|
+
"""
|
|
134
|
+
if value == 0 or not isfinite(value):
|
|
135
|
+
return str(value)
|
|
136
|
+
digits = sig_figs - 1 - floor(log10(abs(value)))
|
|
137
|
+
if digits < 0:
|
|
138
|
+
digits = 0
|
|
139
|
+
return f"{value:.{digits}f}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _plain(value, decimals, sig_figs):
|
|
143
|
+
"""Render a value without scaling, honoring positional or relative
|
|
144
|
+
precision whichever the axis supplied."""
|
|
145
|
+
if decimals is not None:
|
|
146
|
+
return f"{value:.{decimals}f}"
|
|
147
|
+
return _sig(value, sig_figs)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _strip_zeros(text):
|
|
151
|
+
"""Remove trailing zeros and a trailing point from a mantissa string."""
|
|
152
|
+
if "." not in text:
|
|
153
|
+
return text
|
|
154
|
+
text = text.rstrip("0").rstrip(".")
|
|
155
|
+
return text or "0"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _scientific(value, sig_figs):
|
|
159
|
+
"""Render value as m x 10^n with 1 <= |m| < 10."""
|
|
160
|
+
if value == 0 or not isfinite(value):
|
|
161
|
+
return _plain(value, 0, sig_figs) if value == 0 else str(value)
|
|
162
|
+
sign = "-" if value < 0 else ""
|
|
163
|
+
mant = abs(value)
|
|
164
|
+
exponent = floor(log10(mant))
|
|
165
|
+
scaled = mant / (10**exponent)
|
|
166
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
167
|
+
if float(text) >= 10: # rounding pushed the mantissa into the next decade
|
|
168
|
+
scaled = mant / (10 ** (exponent + 1))
|
|
169
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
170
|
+
exponent += 1
|
|
171
|
+
return f"{sign}{text}{_times_ten(exponent)}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _engineering(value, sig_figs):
|
|
175
|
+
"""Render value as m x 10^n with n a multiple of 3 and 1 <= |m| < 1000."""
|
|
176
|
+
if value == 0 or not isfinite(value):
|
|
177
|
+
return _plain(value, 0, sig_figs) if value == 0 else str(value)
|
|
178
|
+
sign = "-" if value < 0 else ""
|
|
179
|
+
mant = abs(value)
|
|
180
|
+
exponent = 3 * floor(floor(log10(mant)) / 3)
|
|
181
|
+
scaled = mant / (10**exponent)
|
|
182
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
183
|
+
if float(text) >= 1000: # rounding overflowed the mantissa past 1000
|
|
184
|
+
exponent += 3
|
|
185
|
+
scaled = mant / (10**exponent)
|
|
186
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
187
|
+
return f"{sign}{text}{_times_ten(exponent)}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _e_notation(value, sig_figs):
|
|
191
|
+
"""Render value as the ASCII E form, e.g. 1.23e4."""
|
|
192
|
+
if value == 0 or not isfinite(value):
|
|
193
|
+
return _plain(value, 0, sig_figs) if value == 0 else str(value)
|
|
194
|
+
sign = "-" if value < 0 else ""
|
|
195
|
+
mant = abs(value)
|
|
196
|
+
exponent = floor(log10(mant))
|
|
197
|
+
scaled = mant / (10**exponent)
|
|
198
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
199
|
+
if float(text) >= 10:
|
|
200
|
+
scaled = mant / (10 ** (exponent + 1))
|
|
201
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
202
|
+
exponent += 1
|
|
203
|
+
return f"{sign}{text}e{exponent}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _si_prefix(value, sig_figs):
|
|
207
|
+
"""Render value with an SI prefix from the SI-prefix table."""
|
|
208
|
+
if value == 0 or not isfinite(value):
|
|
209
|
+
return _plain(value, 0, sig_figs) if value == 0 else str(value)
|
|
210
|
+
mant = abs(value)
|
|
211
|
+
exponent = 3 * floor(floor(log10(mant)) / 3)
|
|
212
|
+
prefix = _SI_PREFIXES.get(exponent, "")
|
|
213
|
+
scaled = mant / (10**exponent)
|
|
214
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
215
|
+
if float(text) >= 1000: # rounding spilled into the next prefix band
|
|
216
|
+
exponent += 3
|
|
217
|
+
prefix = _SI_PREFIXES.get(exponent, "")
|
|
218
|
+
scaled = mant / (10**exponent)
|
|
219
|
+
text = _strip_zeros(_sig(scaled, sig_figs))
|
|
220
|
+
suffix = (" " + prefix) if prefix else ""
|
|
221
|
+
return f"{text}{suffix}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _auto_choose(value):
|
|
225
|
+
"""Pick the format for 'auto': plain unless the value is huge/tiny."""
|
|
226
|
+
if value == 0:
|
|
227
|
+
return PLAIN
|
|
228
|
+
magnitude = abs(value)
|
|
229
|
+
if magnitude >= _HIGH or magnitude < _LOW:
|
|
230
|
+
return SCIENTIFIC
|
|
231
|
+
return PLAIN
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def render(value, fmt, decimals, sig_figs):
|
|
235
|
+
"""Return value as a string in the given format.
|
|
236
|
+
|
|
237
|
+
decimals is the positional precision (None for a log axis); sig_figs
|
|
238
|
+
is the relative precision (None for a linear axis). One of the two is
|
|
239
|
+
always provided. An unknown fmt falls back to plain.
|
|
240
|
+
"""
|
|
241
|
+
if fmt == AUTO:
|
|
242
|
+
fmt = _auto_choose(value)
|
|
243
|
+
if fmt == PLAIN or fmt not in NAMES:
|
|
244
|
+
return _plain(value, decimals, sig_figs)
|
|
245
|
+
if decimals is not None and sig_figs is None:
|
|
246
|
+
# A linear axis has positional precision, which for a large value
|
|
247
|
+
# would balloon into many significant figures (e.g. a whole number
|
|
248
|
+
# to 1 decimal is ~9 sig figs). That is honest but unreadable, so
|
|
249
|
+
# clamp the exponent styles to a few figures like a hand readout.
|
|
250
|
+
sig_figs = _sig_figs_for_decimals(value, decimals)
|
|
251
|
+
sig_figs = max(2, min(sig_figs, _MAX_SIG_FIGS))
|
|
252
|
+
if sig_figs is None:
|
|
253
|
+
sig_figs = _sig_figs_for_decimals(value, decimals) or 3
|
|
254
|
+
if fmt == SCIENTIFIC:
|
|
255
|
+
return _scientific(value, sig_figs)
|
|
256
|
+
if fmt == ENGINEERING:
|
|
257
|
+
return _engineering(value, sig_figs)
|
|
258
|
+
if fmt == E:
|
|
259
|
+
return _e_notation(value, sig_figs)
|
|
260
|
+
if fmt == SI:
|
|
261
|
+
return _si_prefix(value, sig_figs)
|
|
262
|
+
return _plain(value, decimals, sig_figs)
|
plotruler/hotkey.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Global hotkey support via the Win32 RegisterHotKey API.
|
|
2
|
+
|
|
3
|
+
A tray-resident overlay needs a system-wide key to summon and dismiss it
|
|
4
|
+
without the window having focus. On Windows this is RegisterHotKey, which
|
|
5
|
+
posts a WM_HOTKEY message to the thread that registered it. Qt's event
|
|
6
|
+
loop delivers that message to a QAbstractNativeEventFilter, which is how
|
|
7
|
+
we receive it regardless of which window is focused.
|
|
8
|
+
|
|
9
|
+
The key combination is configurable, not hard-coded, so users can avoid
|
|
10
|
+
collisions with other software (the default is Win+Alt+P for PlotRuler).
|
|
11
|
+
|
|
12
|
+
Windows-only. On other platforms registration is a no-op.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import ctypes
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from ctypes import wintypes
|
|
20
|
+
except (ImportError, OSError):
|
|
21
|
+
wintypes = None
|
|
22
|
+
|
|
23
|
+
from PySide6.QtCore import QAbstractNativeEventFilter
|
|
24
|
+
|
|
25
|
+
# ctypes.wintypes imports fine on non-Windows (it is only struct types), so
|
|
26
|
+
# a non-None wintypes does not prove we are on Windows, and calling
|
|
27
|
+
# ctypes.windll on Linux fails with AttributeError. Gate every Win32 path
|
|
28
|
+
# on the real OS.
|
|
29
|
+
_IS_WINDOWS = sys.platform == "win32" and wintypes is not None
|
|
30
|
+
|
|
31
|
+
MOD_ALT = 0x0001
|
|
32
|
+
MOD_CONTROL = 0x0002
|
|
33
|
+
MOD_SHIFT = 0x0004
|
|
34
|
+
MOD_WIN = 0x0008
|
|
35
|
+
|
|
36
|
+
MOD_BY_NAME = {
|
|
37
|
+
"win": MOD_WIN,
|
|
38
|
+
"alt": MOD_ALT,
|
|
39
|
+
"ctrl": MOD_CONTROL,
|
|
40
|
+
"shift": MOD_SHIFT,
|
|
41
|
+
}
|
|
42
|
+
MOD_NAME_BY_BIT = {bit: name for name, bit in MOD_BY_NAME.items()}
|
|
43
|
+
|
|
44
|
+
WM_HOTKEY = 0x0312
|
|
45
|
+
|
|
46
|
+
# Default summon key: Win+Alt+P (P for PlotRuler). Unclaimed on stock
|
|
47
|
+
# Windows; the only known collision is PowerToys' optional mouse-pointer
|
|
48
|
+
# crosshairs, which users can remap here.
|
|
49
|
+
DEFAULT_VK = 0x50 # 'P'
|
|
50
|
+
DEFAULT_MODIFIERS = MOD_WIN | MOD_ALT
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KeyCombo:
|
|
54
|
+
"""A single key plus its modifiers, in an OS-agnostic form.
|
|
55
|
+
|
|
56
|
+
Modifiers are stored as a frozenset of names ("win", "alt", "ctrl",
|
|
57
|
+
"shift") so they serialize as a stable ordered list. The key is a
|
|
58
|
+
human name ("P", "F1", "Space") plus its Win32 virtual-key code. The
|
|
59
|
+
overlay and settings dialog build these; the Win32 GlobalHotkey turns
|
|
60
|
+
one into a RegisterHotKey call.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, modifiers, key_name, vk):
|
|
64
|
+
self.modifiers = frozenset(modifiers)
|
|
65
|
+
self.key_name = key_name
|
|
66
|
+
self.vk = int(vk)
|
|
67
|
+
|
|
68
|
+
def text(self):
|
|
69
|
+
"""Return a human-readable form, e.g. 'Win+Alt+P'."""
|
|
70
|
+
order = ("win", "alt", "ctrl", "shift")
|
|
71
|
+
parts = [name.title() for name in order if name in self.modifiers]
|
|
72
|
+
parts.append(self.key_name)
|
|
73
|
+
return "+".join(parts)
|
|
74
|
+
|
|
75
|
+
def win32_modifiers(self):
|
|
76
|
+
"""Return the Win32 modifier bitmask for this combo."""
|
|
77
|
+
mask = 0
|
|
78
|
+
for name in self.modifiers:
|
|
79
|
+
mask |= MOD_BY_NAME.get(name, 0)
|
|
80
|
+
return mask
|
|
81
|
+
|
|
82
|
+
def to_dict(self):
|
|
83
|
+
"""Return a storage-safe dict for this combo."""
|
|
84
|
+
order = ("win", "alt", "ctrl", "shift")
|
|
85
|
+
mods = [name for name in order if name in self.modifiers]
|
|
86
|
+
return {"modifiers": mods, "key": self.key_name, "vk": self.vk}
|
|
87
|
+
|
|
88
|
+
def __eq__(self, other):
|
|
89
|
+
return (
|
|
90
|
+
isinstance(other, KeyCombo)
|
|
91
|
+
and self.modifiers == other.modifiers
|
|
92
|
+
and self.key_name == other.key_name
|
|
93
|
+
and self.vk == other.vk
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __hash__(self):
|
|
97
|
+
return hash((tuple(sorted(self.modifiers)), self.key_name, self.vk))
|
|
98
|
+
|
|
99
|
+
def __repr__(self):
|
|
100
|
+
return f"KeyCombo({self.text()})"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
DEFAULT_COMBO = KeyCombo(("win", "alt"), "P", DEFAULT_VK)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def combo_from_dict(data):
|
|
107
|
+
"""Build a KeyCombo from a dict, or None if it is malformed."""
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
modifiers = data["modifiers"]
|
|
112
|
+
key_name = str(data["key"])
|
|
113
|
+
vk = int(data["vk"])
|
|
114
|
+
except (KeyError, TypeError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
clean = [name for name in modifiers if name in MOD_BY_NAME]
|
|
117
|
+
if not clean or not key_name:
|
|
118
|
+
return None
|
|
119
|
+
return KeyCombo(clean, key_name, vk)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def qkey_to_vk(qt_key):
|
|
123
|
+
"""Convert a Qt.Key alias to a Win32 virtual-key code.
|
|
124
|
+
|
|
125
|
+
For letters and digits Qt already uses the VK code, but function and
|
|
126
|
+
other special keys use a Qt-specific value that must be mapped. F1-F12
|
|
127
|
+
are contiguous in Qt (0x01000030..0x0100003B) and in Win32
|
|
128
|
+
(0x70..0x7B), so they can be computed; the rest come from a table.
|
|
129
|
+
"""
|
|
130
|
+
# Qt.Key.F1 = 0x01000030 == VK_F1 = 0x70, and both run contiguously.
|
|
131
|
+
if 0x01000030 <= qt_key <= 0x0100003B:
|
|
132
|
+
return 0x70 + (qt_key - 0x01000030)
|
|
133
|
+
special = {
|
|
134
|
+
0x01000006: 0x2D, # Insert
|
|
135
|
+
0x01000007: 0x2E, # Delete
|
|
136
|
+
0x01000010: 0x24, # Home
|
|
137
|
+
0x01000011: 0x23, # End
|
|
138
|
+
0x01000012: 0x25, # Left
|
|
139
|
+
0x01000013: 0x26, # Up
|
|
140
|
+
0x01000014: 0x27, # Right
|
|
141
|
+
0x01000015: 0x28, # Down
|
|
142
|
+
0x01000016: 0x21, # Page Up
|
|
143
|
+
0x01000017: 0x22, # Page Down
|
|
144
|
+
0x01000020: 0x2C, # PrintScreen
|
|
145
|
+
0x20: 0x20, # Space (VK is 0x20, same as Qt)
|
|
146
|
+
}
|
|
147
|
+
if qt_key in special:
|
|
148
|
+
return special[qt_key]
|
|
149
|
+
# Qt reserves 0x01000000-0x0100FFFF for special keys; below that an
|
|
150
|
+
# ASCII/ANSI key's value is already the VK code.
|
|
151
|
+
if 0x01000000 <= qt_key <= 0x0100FFFF:
|
|
152
|
+
return 0
|
|
153
|
+
return qt_key
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def qmodifiers_to_names(qt_modifiers):
|
|
157
|
+
"""Convert a Qt.KeyboardModifier bitmask to modifier names.
|
|
158
|
+
|
|
159
|
+
Accepts either a KeyboardModifier flag (from a QKeyEvent) or a plain
|
|
160
|
+
int bitmask, since the two come back differently through the bindings.
|
|
161
|
+
Qt5 and Qt6 use different modifier bit values, so we match against the
|
|
162
|
+
flag enums rather than raw ints.
|
|
163
|
+
"""
|
|
164
|
+
from PySide6.QtCore import Qt
|
|
165
|
+
|
|
166
|
+
flags = Qt.KeyboardModifier
|
|
167
|
+
value = getattr(qt_modifiers, "value", qt_modifiers)
|
|
168
|
+
names = []
|
|
169
|
+
# The Windows key appears to Qt as the Meta modifier.
|
|
170
|
+
if value & flags.MetaModifier.value:
|
|
171
|
+
names.append("win")
|
|
172
|
+
if value & flags.AltModifier.value:
|
|
173
|
+
names.append("alt")
|
|
174
|
+
if value & flags.ControlModifier.value:
|
|
175
|
+
names.append("ctrl")
|
|
176
|
+
if value & flags.ShiftModifier.value:
|
|
177
|
+
names.append("shift")
|
|
178
|
+
return names
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class GlobalHotkey(QAbstractNativeEventFilter):
|
|
182
|
+
"""Registers one global hotkey and calls a callback when pressed.
|
|
183
|
+
|
|
184
|
+
The callback is invoked on the Qt main thread from within the native
|
|
185
|
+
event filter, so it is safe to manipulate widgets directly.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, combo, callback=None):
|
|
189
|
+
super().__init__()
|
|
190
|
+
self._combo = combo
|
|
191
|
+
self._callback = callback
|
|
192
|
+
self._hotkey_id = 0xBEEF
|
|
193
|
+
self._registered = False
|
|
194
|
+
|
|
195
|
+
def register(self):
|
|
196
|
+
"""Register the hotkey; returns True on success."""
|
|
197
|
+
if not _IS_WINDOWS:
|
|
198
|
+
return False
|
|
199
|
+
if self._registered:
|
|
200
|
+
return True
|
|
201
|
+
result = ctypes.windll.user32.RegisterHotKey(
|
|
202
|
+
None,
|
|
203
|
+
self._hotkey_id,
|
|
204
|
+
self._combo.win32_modifiers(),
|
|
205
|
+
self._combo.vk,
|
|
206
|
+
)
|
|
207
|
+
self._registered = bool(result)
|
|
208
|
+
return self._registered
|
|
209
|
+
|
|
210
|
+
def unregister(self):
|
|
211
|
+
"""Remove the hotkey if it was registered."""
|
|
212
|
+
if self._registered and _IS_WINDOWS:
|
|
213
|
+
ctypes.windll.user32.UnregisterHotKey(None, self._hotkey_id)
|
|
214
|
+
self._registered = False
|
|
215
|
+
|
|
216
|
+
def set_callback(self, callback):
|
|
217
|
+
self._callback = callback
|
|
218
|
+
|
|
219
|
+
def set_combo(self, combo):
|
|
220
|
+
"""Swap the registered key for a new combo.
|
|
221
|
+
|
|
222
|
+
Unregisters the old hotkey, registers the new one, and returns the
|
|
223
|
+
new registration result.
|
|
224
|
+
"""
|
|
225
|
+
self.unregister()
|
|
226
|
+
self._combo = combo
|
|
227
|
+
return self.register()
|
|
228
|
+
|
|
229
|
+
def nativeEventFilter(self, event_type, message):
|
|
230
|
+
if not _IS_WINDOWS or event_type != b"windows_generic_MSG":
|
|
231
|
+
return False, 0
|
|
232
|
+
try:
|
|
233
|
+
msg = wintypes.MSG.from_address(int(message))
|
|
234
|
+
except (TypeError, ValueError):
|
|
235
|
+
return False, 0
|
|
236
|
+
if msg.message == WM_HOTKEY and msg.wParam == self._hotkey_id:
|
|
237
|
+
if self._callback is not None:
|
|
238
|
+
self._callback()
|
|
239
|
+
return True, 0
|
|
240
|
+
return False, 0
|