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 ADDED
@@ -0,0 +1,3 @@
1
+ """PlotRuler — read (X, Y) values off an on-screen graph."""
2
+
3
+ __version__ = "0.1.3"
plotruler/__main__.py ADDED
@@ -0,0 +1,98 @@
1
+ """Run PlotRuler with `python -m plotruler`."""
2
+
3
+ import faulthandler
4
+ import os
5
+ import sys
6
+ import traceback
7
+
8
+ from PySide6.QtWidgets import QApplication
9
+
10
+ from . import storage
11
+ from .hotkey import DEFAULT_COMBO, GlobalHotkey, combo_from_dict
12
+ from .overlay import OverlayWindow, _config_path
13
+ from .settings import HotkeyDialog
14
+ from .tray import TrayIcon
15
+
16
+
17
+ def _load_hotkey():
18
+ """Return the saved key combo, or the default if none is stored."""
19
+ saved = storage.hotkey(_config_path())
20
+ combo = combo_from_dict(saved) if saved else None
21
+ return combo or DEFAULT_COMBO
22
+
23
+
24
+ def _crash_log_path():
25
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("TEMP") or "."
26
+ return os.path.join(base, "PlotRuler", "crash.log")
27
+
28
+
29
+ def _install_crash_logging():
30
+ """Route fatal signal and Python-exception reports to a log file.
31
+
32
+ A native segfault in Qt produces no Python traceback, so faulthandler
33
+ writes its dump to a file we can read afterward; a Python exception
34
+ that escapes the event loop is caught by sys.excepthook.
35
+ """
36
+ path = _crash_log_path()
37
+ os.makedirs(os.path.dirname(path), exist_ok=True)
38
+ with open(path, "a", encoding="utf-8") as handle:
39
+ handle.write("\n===== PlotRuler start =====\n")
40
+ try:
41
+ # faulthandler writes on fatal signals using the file descriptor it
42
+ # captured; the file is left open despite no Python reference, so a
43
+ # C++ segfault can still be recorded.
44
+ faulthandler.enable(file=open(path, "a", encoding="utf-8"))
45
+ except Exception:
46
+ pass
47
+
48
+ def excepthook(exc_type, exc, tb):
49
+ text = "".join(traceback.format_exception(exc_type, exc, tb))
50
+ with open(path, "a", encoding="utf-8") as handle:
51
+ handle.write(text)
52
+ sys.__excepthook__(exc_type, exc, tb)
53
+
54
+ sys.excepthook = excepthook
55
+
56
+
57
+ def main():
58
+ _install_crash_logging()
59
+ app = QApplication(sys.argv)
60
+ app.setApplicationName("PlotRuler")
61
+ app.setApplicationDisplayName("PlotRuler")
62
+
63
+ window = OverlayWindow()
64
+
65
+ config_path = _config_path()
66
+
67
+ def change_hotkey():
68
+ current = _load_hotkey()
69
+ dialog = HotkeyDialog(current=current)
70
+ if dialog.exec():
71
+ new_combo = dialog.combo()
72
+ if new_combo is not None:
73
+ if hotkey.set_combo(new_combo):
74
+ storage.save(config_path, hotkey=new_combo.to_dict())
75
+ else:
76
+ # The new key is already in use (RegisterHotKey failed);
77
+ # tell the user and leave the old hotkey registered.
78
+ print(
79
+ "Could not register "
80
+ + new_combo.text()
81
+ + "; it may already be in use."
82
+ )
83
+
84
+ # Parent the tray to the window so it lives as long as the app does.
85
+ TrayIcon(window, on_change_hotkey=change_hotkey, parent=window)
86
+ app.setQuitOnLastWindowClosed(False) # stay resident in the tray
87
+
88
+ hotkey = GlobalHotkey(_load_hotkey(), callback=window.toggle_visibility)
89
+ hotkey.register()
90
+ app.installNativeEventFilter(hotkey)
91
+ app.aboutToQuit.connect(hotkey.unregister)
92
+
93
+ window.show()
94
+ sys.exit(app.exec())
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
plotruler/core.py ADDED
@@ -0,0 +1,371 @@
1
+ """Pixel-to-value math for PlotRuler.
2
+
3
+ This module is deliberately free of any Qt imports so it stays
4
+ unit-testable and portable. The overlay layer passes coordinates in
5
+ one consistent space (physical screen pixels); this module turns them
6
+ into data values.
7
+
8
+ Each axis maps screen coordinate to value through two anchor points.
9
+ By default the map is linear (affine): two anchor points define a line
10
+ and any screen coordinate is interpolated (or extrapolated) along it.
11
+ A log axis instead fits the line in logarithmic space, so a fixed pixel
12
+ step yields a fixed multiplicative factor rather than a fixed additive
13
+ step. X and Y are independent, so a full calibration is just two maps.
14
+ """
15
+
16
+ from math import floor, isfinite, log10
17
+
18
+ from .format import AUTO, render
19
+
20
+
21
+ class AxisCalibration:
22
+ """Maps one screen axis to values using two (coordinate, value) anchors.
23
+
24
+ The anchors may be supplied in either order; a line is fit through
25
+ both. Screen coordinates normally grow toward the lower right while
26
+ data values may grow in either direction, so an inverted axis
27
+ (screen down = value up, the usual graph layout) is handled
28
+ naturally by the line fit.
29
+
30
+ When log is True the line is fit on log10(value) instead of value:
31
+ value = 10**(a * coordinate + b). This requires both anchor values
32
+ to be positive (a log scale cannot represent zero or negatives).
33
+ """
34
+
35
+ def __init__(self, p1, v1, p2, v2, log=False):
36
+ if p1 == p2:
37
+ raise ValueError("calibration anchors must be distinct")
38
+ self.p1 = float(p1)
39
+ self.v1 = float(v1)
40
+ self.p2 = float(p2)
41
+ self.v2 = float(v2)
42
+ self.log = bool(log)
43
+ if self.log and not (self.v1 > 0 and self.v2 > 0):
44
+ raise ValueError("log axes require positive anchor values")
45
+
46
+ def _log_slope(self):
47
+ """Decades per pixel, the log-axis analog of scale()."""
48
+ return (log10(self.v2) - log10(self.v1)) / (self.p2 - self.p1)
49
+
50
+ def value(self, p):
51
+ """Return the data value at screen coordinate p.
52
+
53
+ Interpolates between the anchors, or extrapolates past them so
54
+ the readout stays sensible just outside the calibrated region.
55
+ """
56
+ if self.log:
57
+ exponent = log10(self.v1) + (p - self.p1) * self._log_slope()
58
+ result = 10**exponent
59
+ if not isfinite(result):
60
+ raise ValueError("log value is out of range")
61
+ return result
62
+ return self.v1 + (p - self.p1) / (self.p2 - self.p1) * (
63
+ self.v2 - self.v1
64
+ )
65
+
66
+ def scale(self):
67
+ """Return units per pixel for this axis (always positive).
68
+
69
+ Only meaningful for a linear axis; on a log axis a fixed pixel
70
+ step gives a multiplicative factor instead, so precision is
71
+ tracked as significant figures (see format()).
72
+ """
73
+ return abs((self.v2 - self.v1) / (self.p2 - self.p1))
74
+
75
+ def decimals(self, pixel_error=1.0):
76
+ """Return how many decimals to display given click precision.
77
+
78
+ A pixel_error-pixel click error becomes scale() * pixel_error
79
+ units of uncertainty on the readout; we display just enough
80
+ decimals that the rounding unit is no finer than that
81
+ uncertainty, so the readout never over-claims precision.
82
+ """
83
+ uncertainty = self.scale() * pixel_error
84
+ if uncertainty <= 0:
85
+ return 0
86
+ return max(0, -floor(log10(uncertainty)))
87
+
88
+ def _log_significant_figures(self, pixel_error=1.0):
89
+ """Significant figures for a log axis given click precision.
90
+
91
+ A pixel_error-pixel error moves the value by a factor of
92
+ 10**(slope * pixel_error), so the relative uncertainty is
93
+ constant along the axis; we show just enough significant figures
94
+ that the last one is not swamped by that uncertainty. This is
95
+ the log analog of decimals() for a linear axis.
96
+ """
97
+ relative = abs(10 ** (self._log_slope() * pixel_error) - 1)
98
+ if not isfinite(relative) or relative <= 0:
99
+ return 1
100
+ return max(1, -floor(log10(relative)))
101
+
102
+ def format(self, value, pixel_error=1.0, fmt=AUTO):
103
+ """Return value as a string using the requested number format.
104
+
105
+ A linear axis rounds to decimals; a log axis shows a fixed
106
+ number of significant figures because its precision is relative,
107
+ not additive. The number format (plain, scientific, etc.) is
108
+ applied by the formatter module; the axis only supplies the
109
+ precision (decimals for linear, sig-figs for log) it implies.
110
+ """
111
+ decimals = None if self.log else self.decimals(pixel_error)
112
+ sig = self._log_significant_figures(pixel_error) if self.log else None
113
+ return render(value, fmt, decimals, sig)
114
+
115
+ def __repr__(self):
116
+ return (
117
+ f"AxisCalibration({self.p1}, {self.v1}, {self.p2}, {self.v2}, "
118
+ f"log={self.log})"
119
+ )
120
+
121
+
122
+ class Calibration:
123
+ """Two AxisCalibrations, one per screen axis, forming a full 2-D map."""
124
+
125
+ def __init__(self, x, y):
126
+ self.x = x
127
+ self.y = y
128
+
129
+ def xy(self, px, py):
130
+ """Return the (x, y) data values at the screen point (px, py)."""
131
+ return self.x.value(px), self.y.value(py)
132
+
133
+ def region(self):
134
+ """Return the calibrated pixel bounds as (left, top, right, bottom).
135
+
136
+ The X anchors give the horizontal extent and the Y anchors the
137
+ vertical extent, so the calibrated region is the rectangle between
138
+ them regardless of the order the anchors were clicked. The overlay
139
+ draws this as a guide so a misaligned graph underneath is noticed.
140
+ """
141
+ top, bottom = sorted((self.y.p1, self.y.p2))
142
+ left, right = sorted((self.x.p1, self.x.p2))
143
+ return left, top, right, bottom
144
+
145
+ def __repr__(self):
146
+ return f"Calibration(x={self.x}, y={self.y})"
147
+
148
+
149
+ class CalibrationSession:
150
+ """Guides the four click-and-type steps that build a calibration.
151
+
152
+ The user calibrates the X axis first (two points), then the Y axis
153
+ (two points). Each point is a screen position; immediately after
154
+ clicking it the user types the value that the position represents.
155
+ The session is a small state machine over that fixed sequence so the
156
+ overlay can paint a prompt for the step it is waiting on and can
157
+ undo steps or cancel cleanly.
158
+
159
+ Positions are in the same coordinate space the rest of the math
160
+ uses (physical screen pixels); the session does not care what units
161
+ they are in.
162
+ """
163
+
164
+ # The whole flow as a fixed sequence of micro-steps: for each axis a
165
+ # click, a value, a second click, a second value, then a linear/log
166
+ # choice. A plain pointer into this list is the entire state, which
167
+ # makes undo trivial (step back and drop the stored data for the step
168
+ # we return to).
169
+ _STEPS = (
170
+ ("click", "x", 0),
171
+ ("value", "x", 0),
172
+ ("click", "x", 1),
173
+ ("value", "x", 1),
174
+ ("mode", "x", None),
175
+ ("click", "y", 0),
176
+ ("value", "y", 0),
177
+ ("click", "y", 1),
178
+ ("value", "y", 1),
179
+ ("mode", "y", None),
180
+ )
181
+
182
+ def __init__(self):
183
+ self._step = 0
184
+ self._points = {}
185
+ self._values = {}
186
+ # Axis scale mode: "lin" or "log". Set when the mode step for an
187
+ # axis is reached. A log scale needs both values positive, so an
188
+ # axis with a zero (or negative) anchor is forced to linear and
189
+ # the mode step is skipped automatically.
190
+ self._scale_mode = {"x": "lin", "y": "lin"}
191
+
192
+ @property
193
+ def active(self):
194
+ """True while a calibration is still being entered."""
195
+ return self._step < len(self._STEPS)
196
+
197
+ @property
198
+ def expecting_click(self):
199
+ """True when the next step needs a point click."""
200
+ return self.active and self._STEPS[self._step][0] == "click"
201
+
202
+ @property
203
+ def expecting_value(self):
204
+ """True when the next step needs a typed value."""
205
+ return self.active and self._STEPS[self._step][0] == "value"
206
+
207
+ @property
208
+ def expecting_mode(self):
209
+ """True when the next step needs a linear/log choice.
210
+
211
+ This only advances if both anchor values for the axis are
212
+ positive; otherwise the axis cannot be logarithmic, so the mode
213
+ step is skipped and the axis stays linear.
214
+ """
215
+ if not self.active:
216
+ return False
217
+ kind, axis, _index = self._STEPS[self._step]
218
+ if kind != "mode":
219
+ return False
220
+ return self._log_permitted(axis)
221
+
222
+ def _log_permitted(self, axis):
223
+ """True if a log scale is possible for an axis (both values > 0)."""
224
+ v0 = self._values.get((axis, 0))
225
+ v1 = self._values.get((axis, 1))
226
+ return v0 is not None and v1 is not None and v0 > 0 and v1 > 0
227
+
228
+ @property
229
+ def current_axis(self):
230
+ """The axis ('x' or 'y') being calibrated right now, or None.
231
+
232
+ Returns the axis of the current step whether it is awaiting a
233
+ click or a value, so the overlay can draw a live guide line in
234
+ the right orientation while the user aligns a point.
235
+ """
236
+ if not self.active:
237
+ return None
238
+ return self._STEPS[self._step][1]
239
+
240
+ def prompt(self):
241
+ """Return the instruction for the step currently being waited on."""
242
+ if not self.active:
243
+ return "Calibration complete"
244
+ kind, axis, index = self._STEPS[self._step]
245
+ name = "X" if axis == "x" else "Y"
246
+ if kind == "value":
247
+ return f"Type the value at this {name} point, then press Enter"
248
+ if kind == "mode":
249
+ return f"Is the {name} axis linear or log? (click a button)"
250
+ which = "first" if index == 0 else "second"
251
+ return f"Click the {which} {name} point"
252
+
253
+ def record_point(self, px, py):
254
+ """Record a clicked screen position for the current step."""
255
+ if not self.active:
256
+ raise ValueError("calibration is already complete")
257
+ kind, axis, index = self._STEPS[self._step]
258
+ if kind != "click":
259
+ raise ValueError("a value is being requested, not a click")
260
+ self._points[(axis, index)] = (px, py)
261
+ self._step += 1
262
+ self._skip_auto_mode()
263
+
264
+ def record_value(self, value):
265
+ """Attach a numeric value to the point that was just clicked."""
266
+ if not self.active:
267
+ raise ValueError("calibration is already complete")
268
+ kind, axis, index = self._STEPS[self._step]
269
+ if kind != "value":
270
+ raise ValueError("a click is being requested, not a value")
271
+ self._values[(axis, index)] = float(value)
272
+ self._step += 1
273
+ self._skip_auto_mode()
274
+
275
+ def record_mode(self, scale_mode):
276
+ """Choose how the current axis is scaled: 'lin' or 'log'.
277
+
278
+ Called when the session expects a mode step (see expecting_mode).
279
+ Rejects a log scale when the axis has a non-positive anchor value.
280
+ """
281
+ if not self.active:
282
+ raise ValueError("calibration is already complete")
283
+ kind, axis, _index = self._STEPS[self._step]
284
+ if kind != "mode":
285
+ raise ValueError("a mode choice is not being requested")
286
+ if scale_mode == "log" and not self._log_permitted(axis):
287
+ raise ValueError("log scale needs positive anchor values")
288
+ if scale_mode not in ("lin", "log"):
289
+ raise ValueError("scale mode must be 'lin' or 'log'")
290
+ self._scale_mode[axis] = scale_mode
291
+ self._step += 1
292
+
293
+ def _skip_auto_mode(self):
294
+ """Advance past a mode step the user never has to see.
295
+
296
+ A mode step whose axis has a zero or negative anchor cannot be
297
+ logarithmic, so it is fixed to linear and skipped rather than
298
+ stalling the flow waiting for a choice the user cannot make.
299
+ """
300
+ while self.active and self._STEPS[self._step][0] == "mode":
301
+ axis = self._STEPS[self._step][1]
302
+ if self._log_permitted(axis):
303
+ break
304
+ self._scale_mode[axis] = "lin"
305
+ self._step += 1
306
+
307
+ def undo(self):
308
+ """Undo the most recent step, clearing its stored data.
309
+
310
+ After a value is typed, undo returns to the value prompt with the
311
+ point still marked. After a click, undo removes the point too.
312
+ """
313
+ if self._step == 0:
314
+ return
315
+ self._step -= 1
316
+ # Skip back over a mode step the user never saw (auto-skipped
317
+ # because log was impossible), so undo lands on a real step.
318
+ while self._step > 0 and self._STEPS[self._step][0] == "mode":
319
+ axis = self._STEPS[self._step][1]
320
+ if self._log_permitted(axis):
321
+ break
322
+ self._step -= 1
323
+ if self._step == 0:
324
+ return
325
+ kind, axis, index = self._STEPS[self._step]
326
+ if kind == "click":
327
+ self._points.pop((axis, index), None)
328
+ elif kind == "value":
329
+ self._values.pop((axis, index), None)
330
+ else:
331
+ # Mode step: revert the axis to linear so it can be re-picked.
332
+ self._scale_mode[axis] = "lin"
333
+
334
+ def anchors(self):
335
+ """Return the points entered so far, oldest first.
336
+
337
+ Each item is (axis, index, px, py, value); value is None until
338
+ the point's value has been typed. The overlay uses this to paint
339
+ the anchor markers.
340
+ """
341
+ result = []
342
+ for key, (px, py) in sorted(self._points.items()):
343
+ axis, index = key
344
+ result.append((axis, index, px, py, self._values.get(key)))
345
+ return result
346
+
347
+ def calibration(self):
348
+ """Return the Calibration for the entered anchors, or None.
349
+
350
+ A calibration only exists once every point has a value. A
351
+ degenerate axis (both clicks at the same coordinate) cannot form
352
+ a line, so it yields None too.
353
+ """
354
+ if self.active:
355
+ return None
356
+ try:
357
+ x = self._axis_calibration("x")
358
+ y = self._axis_calibration("y")
359
+ except ValueError:
360
+ return None
361
+ return Calibration(x, y)
362
+
363
+ def _axis_calibration(self, axis):
364
+ def coord(key):
365
+ px, py = self._points[key]
366
+ return (px if axis == "x" else py), self._values[key]
367
+
368
+ p0, v0 = coord((axis, 0))
369
+ p1, v1 = coord((axis, 1))
370
+ log = self._scale_mode.get(axis, "lin") == "log"
371
+ return AxisCalibration(p0, v0, p1, v1, log=log)