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/win_hittest.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Win32 shim that gives the frameless overlay native window behavior.
|
|
2
|
+
|
|
3
|
+
A frameless window has no native frame, so Windows refuses to move,
|
|
4
|
+
resize, or snap it, and external tools like GridMove cannot grab it. On
|
|
5
|
+
its own, answering WM_NCHITTEST with HTCAPTION gives dragging but not
|
|
6
|
+
snapping, because Qt's FramelessWindowHint creates the window as
|
|
7
|
+
WS_POPUP and the OS will not run its modal move loop for such a window.
|
|
8
|
+
|
|
9
|
+
The fix has three parts, all in this module:
|
|
10
|
+
|
|
11
|
+
1. Give the window the native overlapped-window style bits (WS_CAPTION,
|
|
12
|
+
WS_THICKFRAME, WS_MAXIMIZEBOX, ...) so Windows treats it as an
|
|
13
|
+
ordinary application window. This is what makes it snappable.
|
|
14
|
+
2. Hide the actual frame by answering WM_NCCALCSIZE and collapsing the
|
|
15
|
+
non-client area, so the native title bar and borders are never drawn.
|
|
16
|
+
3. Answer WM_NCHITTEST so the custom title bar behaves as a caption and
|
|
17
|
+
the edges resize natively, and WM_GETMINMAXINFO so the window's
|
|
18
|
+
minimum size is respected and maximizing fills the work area exactly.
|
|
19
|
+
|
|
20
|
+
Windows-only. On other platforms every function is a no-op.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import ctypes
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from ctypes import wintypes
|
|
28
|
+
except (ImportError, OSError):
|
|
29
|
+
wintypes = None
|
|
30
|
+
|
|
31
|
+
from PySide6.QtCore import QPoint
|
|
32
|
+
|
|
33
|
+
# ctypes.wintypes imports fine on every platform (it is only a module of
|
|
34
|
+
# struct types); ctypes.windll is what is Windows-only. So wintypes being
|
|
35
|
+
# non-None is not proof we are on Windows, and a windll call on Linux dies
|
|
36
|
+
# with AttributeError. Gate every Win32 path in this file on the real OS.
|
|
37
|
+
_IS_WINDOWS = sys.platform == "win32" and wintypes is not None
|
|
38
|
+
|
|
39
|
+
debug_enabled = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _debug(msg):
|
|
43
|
+
if debug_enabled:
|
|
44
|
+
import sys
|
|
45
|
+
|
|
46
|
+
print("[hittest]", msg, file=sys.stderr, flush=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
WM_NCHITTEST = 0x0084
|
|
50
|
+
WM_NCLBUTTONDBLCLK = 0x00A3
|
|
51
|
+
WM_NCCALCSIZE = 0x0083
|
|
52
|
+
WM_GETMINMAXINFO = 0x0024
|
|
53
|
+
|
|
54
|
+
HTCLIENT = 1
|
|
55
|
+
HTCAPTION = 2
|
|
56
|
+
HTLEFT = 10
|
|
57
|
+
HTRIGHT = 11
|
|
58
|
+
HTTOP = 12
|
|
59
|
+
HTTOPLEFT = 13
|
|
60
|
+
HTTOPRIGHT = 14
|
|
61
|
+
HTBOTTOM = 15
|
|
62
|
+
HTBOTTOMLEFT = 16
|
|
63
|
+
HTBOTTOMRIGHT = 17
|
|
64
|
+
|
|
65
|
+
GWL_STYLE = -16
|
|
66
|
+
GWL_EXSTYLE = -20
|
|
67
|
+
WS_POPUP = 0x80000000
|
|
68
|
+
WS_CAPTION = 0x00C00000
|
|
69
|
+
WS_THICKFRAME = 0x00040000
|
|
70
|
+
WS_SYSMENU = 0x00080000
|
|
71
|
+
WS_MINIMIZEBOX = 0x00020000
|
|
72
|
+
WS_MAXIMIZEBOX = 0x00010000
|
|
73
|
+
WS_OVERLAPPEDWINDOW = (
|
|
74
|
+
WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX
|
|
75
|
+
)
|
|
76
|
+
WS_EX_TOOLWINDOW = 0x00000080
|
|
77
|
+
WS_EX_TOPMOST = 0x00000008
|
|
78
|
+
WS_EX_LAYERED = 0x00080000
|
|
79
|
+
|
|
80
|
+
SWP_NOSIZE = 0x0001
|
|
81
|
+
SWP_NOMOVE = 0x0002
|
|
82
|
+
SWP_NOZORDER = 0x0004
|
|
83
|
+
SWP_NOACTIVATE = 0x0010
|
|
84
|
+
SWP_FRAMECHANGED = 0x0020
|
|
85
|
+
|
|
86
|
+
# Window placement show commands for WINDOWPLACEMENT.showCmd.
|
|
87
|
+
SW_HIDE = 0
|
|
88
|
+
SW_SHOW = 5
|
|
89
|
+
SW_SHOWNORMAL = 1
|
|
90
|
+
SW_SHOWMAXIMIZED = 3
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MINMAXINFO(ctypes.Structure):
|
|
94
|
+
_fields_ = [
|
|
95
|
+
("ptReserved", wintypes.POINT),
|
|
96
|
+
("ptMaxSize", wintypes.POINT),
|
|
97
|
+
("ptMaxPosition", wintypes.POINT),
|
|
98
|
+
("ptMinTrackSize", wintypes.POINT),
|
|
99
|
+
("ptMaxTrackSize", wintypes.POINT),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NCCALCSIZE_PARAMS(ctypes.Structure):
|
|
104
|
+
_fields_ = [
|
|
105
|
+
("rgrc", wintypes.RECT * 3),
|
|
106
|
+
("lppos", ctypes.c_void_p),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _local_point(window, lparam):
|
|
111
|
+
"""Convert a WM_* cursor lParam to the window's local coordinates."""
|
|
112
|
+
x = ctypes.c_short(lparam & 0xFFFF).value
|
|
113
|
+
y = ctypes.c_short((lparam >> 16) & 0xFFFF).value
|
|
114
|
+
# lParam is in physical screen pixels; Qt geometry is logical, so
|
|
115
|
+
# scale down before mapping into the widget.
|
|
116
|
+
dpr = window.devicePixelRatioF()
|
|
117
|
+
logical = QPoint(int(x / dpr), int(y / dpr))
|
|
118
|
+
return window.mapFromGlobal(logical)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def window_rect(window):
|
|
122
|
+
"""Return the window's on-screen rect in physical pixels, or None.
|
|
123
|
+
|
|
124
|
+
Uses GetWindowRect, which reports the actual displayed bounds. This
|
|
125
|
+
matters for a maximized or aero-snapped window, where Qt's geometry()
|
|
126
|
+
returns the pre-snap "restore" rect rather than where the window is
|
|
127
|
+
really sitting on screen.
|
|
128
|
+
"""
|
|
129
|
+
if not _IS_WINDOWS:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
hwnd = int(window.winId())
|
|
133
|
+
rect = wintypes.RECT()
|
|
134
|
+
if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
|
135
|
+
return None
|
|
136
|
+
return rect.left, rect.top, rect.right, rect.bottom
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WINDOWPLACEMENT(ctypes.Structure):
|
|
142
|
+
_fields_ = [
|
|
143
|
+
("length", wintypes.UINT),
|
|
144
|
+
("flags", wintypes.UINT),
|
|
145
|
+
("showCmd", wintypes.UINT),
|
|
146
|
+
("ptMinPosition", wintypes.POINT),
|
|
147
|
+
("ptMaxPosition", wintypes.POINT),
|
|
148
|
+
("rcNormalPosition", wintypes.RECT),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def set_window_rect(window, rect, show=SW_SHOW):
|
|
153
|
+
"""Place the window at a physical (left, top, right, bottom) rect.
|
|
154
|
+
|
|
155
|
+
Uses SetWindowPlacement, which sets the bounds and the window state
|
|
156
|
+
together. That is what makes a snapped window restore correctly: a
|
|
157
|
+
plain move/resize after show() is overridden by Windows re-applying
|
|
158
|
+
its stored snap restore position, whereas SetWindowPlacement overrides
|
|
159
|
+
that stored position in one atomic call.
|
|
160
|
+
"""
|
|
161
|
+
if not _IS_WINDOWS:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
hwnd = int(window.winId())
|
|
165
|
+
left, top, right, bottom = rect
|
|
166
|
+
placement = WINDOWPLACEMENT()
|
|
167
|
+
placement.length = ctypes.sizeof(WINDOWPLACEMENT)
|
|
168
|
+
placement.flags = 0
|
|
169
|
+
placement.showCmd = show
|
|
170
|
+
placement.ptMinPosition.x = 0
|
|
171
|
+
placement.ptMinPosition.y = 0
|
|
172
|
+
placement.ptMaxPosition.x = 0
|
|
173
|
+
placement.ptMaxPosition.y = 0
|
|
174
|
+
placement.rcNormalPosition.left = left
|
|
175
|
+
placement.rcNormalPosition.top = top
|
|
176
|
+
placement.rcNormalPosition.right = right
|
|
177
|
+
placement.rcNormalPosition.bottom = bottom
|
|
178
|
+
ctypes.windll.user32.SetWindowPlacement(hwnd, ctypes.byref(placement))
|
|
179
|
+
except Exception:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def apply_native_overlapped_style(window):
|
|
184
|
+
"""Give the window real overlapped-window styles for snapping.
|
|
185
|
+
|
|
186
|
+
Qt's FramelessWindowHint produces WS_POPUP, which throws away the
|
|
187
|
+
shell behavior attached to the frame styles (snapping, taskbar
|
|
188
|
+
interaction, the move loop). Replace WS_POPUP with the standard
|
|
189
|
+
overlapped-window styles. The frame is never drawn because
|
|
190
|
+
WM_NCCALCSIZE collapses it.
|
|
191
|
+
"""
|
|
192
|
+
if not _IS_WINDOWS:
|
|
193
|
+
return
|
|
194
|
+
hwnd = int(window.winId())
|
|
195
|
+
user32 = ctypes.windll.user32
|
|
196
|
+
style = user32.GetWindowLongW(hwnd, GWL_STYLE) & 0xFFFFFFFF
|
|
197
|
+
style = (style & ~WS_POPUP) | WS_OVERLAPPEDWINDOW
|
|
198
|
+
style &= 0xFFFFFFFF
|
|
199
|
+
# Win32 styles are signed 32-bit; convert before calling back.
|
|
200
|
+
if style >= 0x80000000:
|
|
201
|
+
style -= 0x100000000
|
|
202
|
+
user32.SetWindowLongW(hwnd, GWL_STYLE, style)
|
|
203
|
+
# Asking for a frame recalculation makes Windows re-query the frame
|
|
204
|
+
# (WM_NCCALCSIZE) so the collapsed client rect takes effect at once.
|
|
205
|
+
user32.SetWindowPos(
|
|
206
|
+
hwnd,
|
|
207
|
+
None,
|
|
208
|
+
0,
|
|
209
|
+
0,
|
|
210
|
+
0,
|
|
211
|
+
0,
|
|
212
|
+
SWP_FRAMECHANGED
|
|
213
|
+
| SWP_NOMOVE
|
|
214
|
+
| SWP_NOSIZE
|
|
215
|
+
| SWP_NOZORDER
|
|
216
|
+
| SWP_NOACTIVATE,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def current_styles(window):
|
|
221
|
+
"""Return the window's live style and extended-style words, if any."""
|
|
222
|
+
if not _IS_WINDOWS:
|
|
223
|
+
return None, None
|
|
224
|
+
try:
|
|
225
|
+
hwnd = int(window.winId())
|
|
226
|
+
user32 = ctypes.windll.user32
|
|
227
|
+
style = user32.GetWindowLongW(hwnd, GWL_STYLE) & 0xFFFFFFFF
|
|
228
|
+
exstyle = user32.GetWindowLongW(hwnd, GWL_EXSTYLE) & 0xFFFFFFFF
|
|
229
|
+
return style, exstyle
|
|
230
|
+
except Exception:
|
|
231
|
+
return None, None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def handle_native_event(window, event_type, message):
|
|
235
|
+
"""Route a native event to its handler; returns (handled, result)."""
|
|
236
|
+
if not _IS_WINDOWS or event_type != b"windows_generic_MSG":
|
|
237
|
+
return False, 0
|
|
238
|
+
try:
|
|
239
|
+
msg = wintypes.MSG.from_address(int(message))
|
|
240
|
+
except (TypeError, ValueError):
|
|
241
|
+
return False, 0
|
|
242
|
+
if msg.message == WM_NCHITTEST:
|
|
243
|
+
local = _local_point(window, msg.lParam)
|
|
244
|
+
code = window.hit_test_code(local)
|
|
245
|
+
_debug(f"NCHITTEST at {local.x()},{local.y()} -> HT{code}")
|
|
246
|
+
return True, code
|
|
247
|
+
if msg.message == WM_NCLBUTTONDBLCLK:
|
|
248
|
+
# The default is the system menu; we want maximize/restore like a
|
|
249
|
+
# real title bar.
|
|
250
|
+
if window.hit_test_code(_local_point(window, msg.lParam)) == HTCAPTION:
|
|
251
|
+
window.toggle_maximize()
|
|
252
|
+
return True, 0
|
|
253
|
+
if msg.message == WM_NCCALCSIZE:
|
|
254
|
+
# Collapse the non-client area so no native frame is drawn.
|
|
255
|
+
_collapse_frame(msg)
|
|
256
|
+
return True, 0
|
|
257
|
+
if msg.message == WM_GETMINMAXINFO:
|
|
258
|
+
_set_min_max_info(window, msg.lParam)
|
|
259
|
+
return True, 0
|
|
260
|
+
return False, 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _collapse_frame(msg):
|
|
264
|
+
"""Make the client area fill the whole window (no native frame)."""
|
|
265
|
+
if not msg.lParam:
|
|
266
|
+
return
|
|
267
|
+
if msg.wParam:
|
|
268
|
+
params = NCCALCSIZE_PARAMS.from_address(int(msg.lParam))
|
|
269
|
+
window_rect = params.rgrc[1]
|
|
270
|
+
# The proposed client rect (rgrc[0]) becomes the window rect.
|
|
271
|
+
params.rgrc[0].left = window_rect.left
|
|
272
|
+
params.rgrc[0].top = window_rect.top
|
|
273
|
+
params.rgrc[0].right = window_rect.right
|
|
274
|
+
params.rgrc[0].bottom = window_rect.bottom
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _set_min_max_info(window, lparam):
|
|
278
|
+
"""Enforce minimum size and a maximized size that fills the work area."""
|
|
279
|
+
if not lparam:
|
|
280
|
+
return
|
|
281
|
+
mmi = MINMAXINFO.from_address(int(lparam))
|
|
282
|
+
dpr = window.devicePixelRatioF()
|
|
283
|
+
mmi.ptMinTrackSize.x = int(window.minimumWidth() * dpr)
|
|
284
|
+
mmi.ptMinTrackSize.y = int(window.minimumHeight() * dpr)
|
|
285
|
+
# With the frame collapsed, a normal maximized window would overhang
|
|
286
|
+
# the screen by the frame width. Pin the maximized size to the work
|
|
287
|
+
# area so maximize and snap fill it exactly.
|
|
288
|
+
work = window.screen().availableGeometry()
|
|
289
|
+
mmi.ptMaxSize.x = int(work.width() * dpr)
|
|
290
|
+
mmi.ptMaxSize.y = int(work.height() * dpr)
|
|
291
|
+
mmi.ptMaxPosition.x = int(work.x() * dpr)
|
|
292
|
+
mmi.ptMaxPosition.y = int(work.y() * dpr)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: plotruler
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Read (X, Y) values off an on-screen graph with a translucent overlay
|
|
5
|
+
Project-URL: Homepage, https://github.com/endolith/plotruler
|
|
6
|
+
Project-URL: Repository, https://github.com/endolith/plotruler
|
|
7
|
+
Project-URL: Issues, https://github.com/endolith/plotruler/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/endolith/plotruler/releases
|
|
9
|
+
Author: endolith
|
|
10
|
+
Maintainer: endolith
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2026 endolith
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Keywords: calibration,digitize,digitizer,graph,overlay,plot,readout
|
|
35
|
+
Classifier: Development Status :: 4 - Beta
|
|
36
|
+
Classifier: Environment :: X11 Applications
|
|
37
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
38
|
+
Classifier: Intended Audience :: Science/Research
|
|
39
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
40
|
+
Classifier: Operating System :: MacOS
|
|
41
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
42
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
43
|
+
Classifier: Programming Language :: Python :: 3
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
48
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
49
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
50
|
+
Requires-Python: >=3.10
|
|
51
|
+
Requires-Dist: pyside6==6.11.2
|
|
52
|
+
Provides-Extra: dev
|
|
53
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
54
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
55
|
+
Description-Content-Type: text/markdown
|
|
56
|
+
|
|
57
|
+
# PlotRuler
|
|
58
|
+
|
|
59
|
+
A translucent desktop overlay for accurately reading (X, Y) values off a graph
|
|
60
|
+
shown on screen. Calibrate once against the known plot coordinates (click at two
|
|
61
|
+
points on each axis, and type their labeled values), then hover to read
|
|
62
|
+
coordinates anywhere else, and click to copy.
|
|
63
|
+
|
|
64
|
+
The graph itself is not rendered by PlotRuler — it is whatever other app is on
|
|
65
|
+
screen underneath the overlay (a browser, a PDF viewer, a plotting window, etc.)
|
|
66
|
+
PlotRuler is a **live translucent overlay** that reads through it, not a
|
|
67
|
+
screenshot workflow.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
Calibration and readout all live in **absolute screen coordinates** (physical
|
|
72
|
+
pixels), not coordinates relative to the overlay window. That is what makes the
|
|
73
|
+
overlay feel stable: you can drag or resize the window and the calibrated graph
|
|
74
|
+
box stays glued to the graph underneath wherever it sits on screen. Windows and
|
|
75
|
+
macOS get this natively; Linux/X11 uses the same absolute-coordinate model.
|
|
76
|
+
|
|
77
|
+
Each axis is calibrated independently with two reference points — click once at
|
|
78
|
+
a known pixel on the axis, type its labeled value, then repeat for a second
|
|
79
|
+
point. PlotRuler fits a line through those pairs (linear by default, optional
|
|
80
|
+
log), so it works for any graph where the scale is straight-line or log.
|
|
81
|
+
Everything persists to disk (calibration, window position, number format), so
|
|
82
|
+
the overlay reopens already calibrated.
|
|
83
|
+
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **Hover to read** — a crosshair and a (X, Y) readout follow the cursor once
|
|
87
|
+
calibrated, showing the values under it.
|
|
88
|
+
- **Click to copy** — a click copies the hovered coordinate as e.g.
|
|
89
|
+
`(12.5, 4.0)` and flashes a confirmation.
|
|
90
|
+
- **Number formats** — plain, scientific, engineering, E-notation, SI
|
|
91
|
+
(auto/1..6 via the number keys, or the tray menu).
|
|
92
|
+
- **Calibration** — Ctrl+N starts a fresh one; Ctrl+Z undoes a step; Esc
|
|
93
|
+
cancels. Each axis gets its own linear/log choice.
|
|
94
|
+
- **Custom frameless title bar** — translucent, with minimize, maximize, and
|
|
95
|
+
(on no-tray systems) a close button.
|
|
96
|
+
- **Tray resident** — sits in the system tray; tray click toggles the overlay.
|
|
97
|
+
|
|
98
|
+
## Platform comparison
|
|
99
|
+
|
|
100
|
+
| | Windows | macOS | Linux/X11 |
|
|
101
|
+
|---|---|---|---|
|
|
102
|
+
| Absolute screen coordinates | ✅ native | ✅ native | ✅ (same model) |
|
|
103
|
+
| Overlay readout through the graph | ✅ | ✅ | ✅ |
|
|
104
|
+
| Move / resize the window | ✅ native | ✅ native | ✅ Qt-driven |
|
|
105
|
+
| Calibration survives window move/resize | ✅ | ✅ | ✅ |
|
|
106
|
+
| Global hotkey (Win+Alt+P / Cmd+Alt+P) | ✅ RegisterHotKey | ⏳ | ⏳ deferred (XGrabKey) |
|
|
107
|
+
| System tray | ✅ | ✅ | ⚠️ depends on DE |
|
|
108
|
+
| Build/ship | .exe (PyInstaller) | ⏳ untested | pip / PyPI wheel |
|
|
109
|
+
|
|
110
|
+
**Wayland is not supported yet.** Wayland forbids absolute screen coordinates by
|
|
111
|
+
design and GNOME refuses the workarounds, so the overlay model cannot work there
|
|
112
|
+
without a window-relative rewrite. Linux requires an **X11** session for now;
|
|
113
|
+
see `LINUX_PLAN.md`.
|
|
114
|
+
|
|
115
|
+
Notes on the platform table:
|
|
116
|
+
- **macOS is untested.** The architecture is OS-portable (absolute screen
|
|
117
|
+
coordinates, Qt overlay), and Qt provides a native macOS path, but no macOS
|
|
118
|
+
build or hotkey code exists in this repo yet. Treat the macOS column as a
|
|
119
|
+
design feature, not a shipped one.
|
|
120
|
+
- **Global hotkey** — Windows-only today. On Linux the tray (or Ctrl+N/Esc and
|
|
121
|
+
the title-bar buttons) are the controls; an X11 `XGrabKey` hotkey is a
|
|
122
|
+
follow-up.
|
|
123
|
+
- **System tray** — always present on Windows; on Linux it depends on the
|
|
124
|
+
desktop environment. GNOME has no tray by default unless the *AppIndicator
|
|
125
|
+
and KStatusNotifier* extension is installed. When no tray exists, PlotRuler
|
|
126
|
+
shows a close button and quits on minimize/close/Esc rather than hiding into
|
|
127
|
+
an unreachable state.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
conda activate plotruler
|
|
133
|
+
python -m plotruler # run the app
|
|
134
|
+
pytest # run tests
|
|
135
|
+
ruff check . # lint
|
|
136
|
+
ruff format . # auto-format
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Linux
|
|
140
|
+
|
|
141
|
+
Linux requires an X11 session (Wayland is deferred — see above). Qt 6.5+
|
|
142
|
+
also needs the `xcb-cursor` system library for the X11 backend, which pip
|
|
143
|
+
cannot install:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
# Debian / Ubuntu
|
|
147
|
+
sudo apt install libxcb-cursor0
|
|
148
|
+
# Fedora / RHEL
|
|
149
|
+
sudo dnf install xcb-util-cursor
|
|
150
|
+
# Arch
|
|
151
|
+
sudo pacman -S xcb-util-cursor
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Install and run (no frozen binary on Linux):
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
pip install plotruler # or, from source: pip install -e . then plotruler
|
|
158
|
+
plotruler
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> PyPI/wheels cannot install system libraries like `libxcb-cursor`; without it
|
|
162
|
+
> the app aborts at first launch. Install it first (commands above). The
|
|
163
|
+
> `build/install_linux.sh` helper also checks for it and fails fast with your
|
|
164
|
+
> distro's package name.
|
|
165
|
+
|
|
166
|
+
## Releases & publishing
|
|
167
|
+
|
|
168
|
+
PlotRuler ships through **two channels**:
|
|
169
|
+
|
|
170
|
+
| Channel | Artifact | Who uses it |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| **PyPI** (`pip install plotruler`) | wheel (`.whl`) | everyone, all platforms; the Linux and pip path |
|
|
173
|
+
| **GitHub Releases** | `PlotRuler.exe` | Windows users who don't want pip |
|
|
174
|
+
|
|
175
|
+
Publish a Python release to PyPI:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
pip install build twine
|
|
179
|
+
./build/publish_pypi.sh --check # first: upload to TestPyPI and verify
|
|
180
|
+
./build/publish_pypi.sh # then: upload to PyPI
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Requires `TWINE_USERNAME`/`TWINE_PASSWORD` (or a `~/.pypirc`), and an account
|
|
184
|
+
with upload rights for the `plotruler` name. The package supports Python 3.10+
|
|
185
|
+
(down to PySide6 6.11's own floor).
|
|
186
|
+
|
|
187
|
+
The Windows `.exe` is separate and must be built on Windows.
|
|
188
|
+
|
|
189
|
+
## Build a stand-alone Windows executable
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
python -m pip install pyinstaller
|
|
193
|
+
python build/build.py
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Produces a single-file, windowed `dist/PlotRuler.exe` with a tray icon and
|
|
197
|
+
version metadata. The app bundles Qt, so the exe is ~46 MB but needs no Python
|
|
198
|
+
install to run. This is the artifact you attach to a GitHub release.
|
|
199
|
+
|
|
200
|
+
See `AGENTS.md` for development conventions.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT License (see `LICENSE`).
|
|
205
|
+
|
|
206
|
+
(This was generated almost entirely by AI under human direction, so likely lacks the human authorship required for copyright in the US and is therefore in the public domain. MIT license applies anywhere else.)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
plotruler/__init__.py,sha256=m4YbXPapznnOV6dQ7Fvu3rYku1TPvViZrjhyt-QxXlk,86
|
|
2
|
+
plotruler/__main__.py,sha256=QZZ30UQ20lRlIy8olH2fKym-DJKMD8KkJ8TkPWU0Y_c,3238
|
|
3
|
+
plotruler/core.py,sha256=JrkxEFEwJIWiAPNbmxCOHDIu_ZiPT2lqYceU0I6hkdg,14548
|
|
4
|
+
plotruler/format.py,sha256=iIA2tun9WZCokTgtvhcPbjz_tywG-XoskN8YsK-GHIE,8688
|
|
5
|
+
plotruler/hotkey.py,sha256=9tlI8BkEbgsoP_ODojIHbk3TB4yhsFKxmWjrxXT-chA,8010
|
|
6
|
+
plotruler/overlay.py,sha256=JFRe1MWaLXyxebOoZhXXuZIxf8CN1kZMI6aTQgsSRxk,44465
|
|
7
|
+
plotruler/settings.py,sha256=9Q-OMCKJJgVf1Fay5_rea7PT3HdfLQaxn2MGe_sK-oI,4348
|
|
8
|
+
plotruler/storage.py,sha256=y35jXIMO826kEZVjNgkpHndEOWhJsk016sgxS6LsWok,4360
|
|
9
|
+
plotruler/titlebar.py,sha256=pOSPkM_SJI0taQ7gW_RwdLxqFLndhAEji-oFa7d6kLE,8720
|
|
10
|
+
plotruler/tray.py,sha256=y5IPHF_KiemFzwqLbs5kFzQnZ_FjyqbrvPAehpjVzYk,7104
|
|
11
|
+
plotruler/win_hittest.py,sha256=OnHUyi3iqWDrQfOYOHKacWe6NHzGmL1b1ktiMczb_kA,9756
|
|
12
|
+
plotruler-0.1.3.dist-info/METADATA,sha256=CfTMi8p1O9GBHaaI2C8yV4jeQn9rZ_eFBu_t7SQXZNU,8729
|
|
13
|
+
plotruler-0.1.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
plotruler-0.1.3.dist-info/licenses/LICENSE,sha256=9TX-edZF9gflRnx-Y46LPq4k4R1xasFwbGHLvQCTkHI,1066
|
|
15
|
+
plotruler-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 endolith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|