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/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