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/overlay.py
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
"""The translucent overlay window.
|
|
2
|
+
|
|
3
|
+
One frameless, always-on-top, semi-transparent window that floats over
|
|
4
|
+
the graph. It spans a region of the screen (user-sizable and movable);
|
|
5
|
+
the graph shows through because the background is painted translucent.
|
|
6
|
+
Calibration and readout geometry all live in absolute screen
|
|
7
|
+
coordinates, so moving or resizing this window never affects them.
|
|
8
|
+
|
|
9
|
+
The custom title bar is drawn by TitleBar. Moving, resizing, snapping,
|
|
10
|
+
and GridMove compatibility come from the Win32 hit-test shim, which
|
|
11
|
+
makes the OS treat the title bar as a caption and the edges as real
|
|
12
|
+
borders. A transparent margin below the title bar remains empty until
|
|
13
|
+
the calibration flow (and readout) are wired in later.
|
|
14
|
+
|
|
15
|
+
Set PLOTRULER_DEBUG=1 to log window state and native events to stderr.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from PySide6.QtCore import (
|
|
22
|
+
QEvent,
|
|
23
|
+
QPoint,
|
|
24
|
+
QPointF,
|
|
25
|
+
QRect,
|
|
26
|
+
QStandardPaths,
|
|
27
|
+
Qt,
|
|
28
|
+
QTimer,
|
|
29
|
+
)
|
|
30
|
+
from PySide6.QtGui import (
|
|
31
|
+
QColor,
|
|
32
|
+
QFont,
|
|
33
|
+
QFontMetricsF,
|
|
34
|
+
QPainter,
|
|
35
|
+
QPen,
|
|
36
|
+
)
|
|
37
|
+
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QWidget
|
|
38
|
+
|
|
39
|
+
from . import storage, win_hittest
|
|
40
|
+
from .core import CalibrationSession
|
|
41
|
+
from .format import AUTO, NAMES, OPTIONS, is_valid
|
|
42
|
+
from .titlebar import TITLEBAR_HEIGHT, TitleBar
|
|
43
|
+
|
|
44
|
+
# Padding for text laid out at the window edges, in logical pixels.
|
|
45
|
+
_EDGE = 8
|
|
46
|
+
|
|
47
|
+
# Width of the invisible resize hit-zones along each border. Larger than
|
|
48
|
+
# the text padding so a cursor near the edge reliably grabs a resize grip
|
|
49
|
+
# rather than falling through to the graph underneath.
|
|
50
|
+
_RESIZE_ZONE = 14
|
|
51
|
+
|
|
52
|
+
# Calibration instruction block: margin between the last instruction row
|
|
53
|
+
# and the window bottom, and the vertical space between stacked rows. The
|
|
54
|
+
# block is bottom-aligned so the group reads pinned near the bottom; the
|
|
55
|
+
# row height is shared by painting and the mode-button hit-test so they
|
|
56
|
+
# never drift.
|
|
57
|
+
_INSTRUCTION_BOTTOM_MARGIN = 12
|
|
58
|
+
_INSTRUCTION_ROW_H = 26
|
|
59
|
+
|
|
60
|
+
# Font sizes, one consistent scale across the overlay. Headline text (the
|
|
61
|
+
# calibration prompt, the typed value, the hovered readout) is TEXT_LARGE;
|
|
62
|
+
# secondary labels (mode buttons, hints, errors, anchor labels) are
|
|
63
|
+
# TEXT_MEDIUM; tertiary captions (format indicator, transient notices) are
|
|
64
|
+
# TEXT_SMALL. Everything uses one of these so the type reads uniformly.
|
|
65
|
+
_TEXT_LARGE = 14
|
|
66
|
+
_TEXT_MEDIUM = 12
|
|
67
|
+
_TEXT_SMALL = 10
|
|
68
|
+
|
|
69
|
+
# Anchor marker colors: X on one hue, Y on another, both chosen to read
|
|
70
|
+
# against dark and light graph content.
|
|
71
|
+
_X_COLOR = QColor(80, 200, 255)
|
|
72
|
+
_Y_COLOR = QColor(255, 200, 80)
|
|
73
|
+
# The calibrated-region guide: a green distinct from the axis hues so it
|
|
74
|
+
# stays visible against black gridlines and does not read as an anchor.
|
|
75
|
+
_REGION_COLOR = QColor(120, 255, 140)
|
|
76
|
+
|
|
77
|
+
# Characters allowed while typing a calibration value.
|
|
78
|
+
_VALID_CHARS = set("0123456789.-+eE")
|
|
79
|
+
|
|
80
|
+
DEBUG = bool(os.environ.get("PLOTRULER_DEBUG"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _config_path():
|
|
84
|
+
"""Return the config file path for the current user.
|
|
85
|
+
|
|
86
|
+
Uses the Qt-standard per-user config location so the file lands where
|
|
87
|
+
the OS expects (e.g. %APPDATA%/PlotRuler on Windows). The path is
|
|
88
|
+
resolved at call time because the app name is set in main().
|
|
89
|
+
"""
|
|
90
|
+
base = (
|
|
91
|
+
QStandardPaths.writableLocation(
|
|
92
|
+
QStandardPaths.StandardLocation.AppConfigLocation
|
|
93
|
+
)
|
|
94
|
+
or "."
|
|
95
|
+
)
|
|
96
|
+
return os.path.join(base, "plotruler.json")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dbg(msg):
|
|
100
|
+
if DEBUG:
|
|
101
|
+
print("[plotruler]", msg, file=sys.stderr, flush=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OverlayWindow(QWidget):
|
|
105
|
+
"""The translucent overlay window itself."""
|
|
106
|
+
|
|
107
|
+
def __init__(self):
|
|
108
|
+
super().__init__()
|
|
109
|
+
self.setWindowFlags(
|
|
110
|
+
Qt.WindowType.FramelessWindowHint
|
|
111
|
+
| Qt.WindowType.WindowStaysOnTopHint
|
|
112
|
+
| Qt.WindowType.Tool
|
|
113
|
+
)
|
|
114
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
115
|
+
self.setMinimumSize(320, 200)
|
|
116
|
+
self.resize(900, 600)
|
|
117
|
+
self.setMouseTracking(True)
|
|
118
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
119
|
+
|
|
120
|
+
# Tray availability decides how close/min/Esc behave. Windows always
|
|
121
|
+
# has a notification area; some desktop screens (e.g. GNOME without
|
|
122
|
+
# the AppIndicator extension) have no tray at all. Without one the
|
|
123
|
+
# overlay has no way to be brought back, so hiding is a dead end.
|
|
124
|
+
self._tray_available = QSystemTrayIcon.isSystemTrayAvailable()
|
|
125
|
+
|
|
126
|
+
self.title_bar = TitleBar(self, show_close=not self._tray_available)
|
|
127
|
+
self.title_bar.setGeometry(0, 0, self.width(), TITLEBAR_HEIGHT)
|
|
128
|
+
|
|
129
|
+
# Calibration state. A session exists only while the user is
|
|
130
|
+
# (or has just finished) clicking anchors and typing values.
|
|
131
|
+
self._session = None
|
|
132
|
+
self._calibration = None
|
|
133
|
+
self._value_text = ""
|
|
134
|
+
self._value_error = None
|
|
135
|
+
self._caret_visible = True
|
|
136
|
+
self._blink_timer = QTimer(self)
|
|
137
|
+
self._blink_timer.setInterval(530)
|
|
138
|
+
self._blink_timer.timeout.connect(self._blink)
|
|
139
|
+
|
|
140
|
+
# Readout number format (auto/plain/scientific/engineering/e/si),
|
|
141
|
+
# loaded from the config file; a transient "format changed" notice
|
|
142
|
+
# is shown when the user switches it.
|
|
143
|
+
self._num_format = AUTO
|
|
144
|
+
self._format_notice = ""
|
|
145
|
+
self._format_timer = QTimer(self)
|
|
146
|
+
self._format_timer.setSingleShot(True)
|
|
147
|
+
self._format_timer.setInterval(900)
|
|
148
|
+
self._format_timer.timeout.connect(self._clear_format_notice)
|
|
149
|
+
|
|
150
|
+
# Tracks the manual maximize state; the window itself stays in a
|
|
151
|
+
# normal window state so nothing conflicts with native snapping.
|
|
152
|
+
self._maximized = False
|
|
153
|
+
self._saved_geometry = None
|
|
154
|
+
|
|
155
|
+
# Readout state: the last hovered cursor position (local logical),
|
|
156
|
+
# and a transient "copied" banner that fades after a short delay.
|
|
157
|
+
self._hover_pos = QPoint()
|
|
158
|
+
self._hover_active = False
|
|
159
|
+
self._hover_mode = None
|
|
160
|
+
self._copy_notice = ""
|
|
161
|
+
self._copy_timer = QTimer(self)
|
|
162
|
+
self._copy_timer.setSingleShot(True)
|
|
163
|
+
self._copy_timer.setInterval(900)
|
|
164
|
+
self._copy_timer.timeout.connect(self._clear_copy_notice)
|
|
165
|
+
|
|
166
|
+
# Debounce geometry writes so dragging or resizing does not hammer
|
|
167
|
+
# the disk on every pixel of movement.
|
|
168
|
+
self._geometry_timer = QTimer(self)
|
|
169
|
+
self._geometry_timer.setSingleShot(True)
|
|
170
|
+
self._geometry_timer.setInterval(600)
|
|
171
|
+
self._geometry_timer.timeout.connect(self._save_geometry)
|
|
172
|
+
|
|
173
|
+
self._config_path = _config_path()
|
|
174
|
+
self._restore_state()
|
|
175
|
+
|
|
176
|
+
# Swap Qt's WS_POPUP for real overlapped-window styles so Windows
|
|
177
|
+
# treats this as a normal window: snapping, drag-to-edge, and the
|
|
178
|
+
# taskbar all work. The frame is hidden by WM_NCCALCSIZE. Must
|
|
179
|
+
# happen before the window is first shown.
|
|
180
|
+
self.winId()
|
|
181
|
+
win_hittest.apply_native_overlapped_style(self)
|
|
182
|
+
if DEBUG:
|
|
183
|
+
win_hittest.debug_enabled = True
|
|
184
|
+
self._debug_timer = QTimer(self)
|
|
185
|
+
self._debug_timer.timeout.connect(self._debug_state)
|
|
186
|
+
self._debug_timer.start(1000)
|
|
187
|
+
|
|
188
|
+
def _debug_state(self):
|
|
189
|
+
g = self.geometry().getRect()
|
|
190
|
+
sa = self.screen().availableGeometry().getRect()
|
|
191
|
+
style, exstyle = win_hittest.current_styles(self)
|
|
192
|
+
style_desc = "n/a"
|
|
193
|
+
if style is not None:
|
|
194
|
+
parts = []
|
|
195
|
+
if style & win_hittest.WS_POPUP:
|
|
196
|
+
parts.append("POPUP")
|
|
197
|
+
if style & win_hittest.WS_CAPTION:
|
|
198
|
+
parts.append("CAPTION")
|
|
199
|
+
if style & win_hittest.WS_THICKFRAME:
|
|
200
|
+
parts.append("THICKFRAME")
|
|
201
|
+
if style & win_hittest.WS_MAXIMIZEBOX:
|
|
202
|
+
parts.append("MAXBOX")
|
|
203
|
+
if style & win_hittest.WS_MINIMIZEBOX:
|
|
204
|
+
parts.append("MINBOX")
|
|
205
|
+
if style & win_hittest.WS_SYSMENU:
|
|
206
|
+
parts.append("SYSMENU")
|
|
207
|
+
style_desc = "|".join(parts) or "none"
|
|
208
|
+
ex_desc = "n/a"
|
|
209
|
+
if exstyle is not None:
|
|
210
|
+
ex_parts = []
|
|
211
|
+
if exstyle & win_hittest.WS_EX_TOOLWINDOW:
|
|
212
|
+
ex_parts.append("TOOLWIN")
|
|
213
|
+
if exstyle & win_hittest.WS_EX_TOPMOST:
|
|
214
|
+
ex_parts.append("TOPMOST")
|
|
215
|
+
if exstyle & win_hittest.WS_EX_LAYERED:
|
|
216
|
+
ex_parts.append("LAYERED")
|
|
217
|
+
ex_desc = "|".join(ex_parts) or "none"
|
|
218
|
+
_dbg(
|
|
219
|
+
f"state max={self.is_maximized()} ws={self.windowState()} "
|
|
220
|
+
f"vis={self.isVisible()} geo={g} work={sa} "
|
|
221
|
+
f"style=[{style_desc}] ex=[{ex_desc}]"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def is_maximized(self):
|
|
225
|
+
return self._maximized
|
|
226
|
+
|
|
227
|
+
def toggle_maximize(self):
|
|
228
|
+
"""Fill the screen, or return to the last window size."""
|
|
229
|
+
_dbg(f"toggle_maximize called, is_max={self._maximized}")
|
|
230
|
+
if self._maximized:
|
|
231
|
+
self.setGeometry(self._saved_geometry)
|
|
232
|
+
self._maximized = False
|
|
233
|
+
else:
|
|
234
|
+
self._saved_geometry = self.geometry()
|
|
235
|
+
# Setting the geometry directly (instead of showMaximized)
|
|
236
|
+
# skips the native slide-to-corner animation that reads as a
|
|
237
|
+
# flash on a frameless overlay.
|
|
238
|
+
self.setGeometry(self.screen().availableGeometry())
|
|
239
|
+
self._maximized = True
|
|
240
|
+
_dbg(
|
|
241
|
+
f"toggle_maximize done, is_max={self._maximized} "
|
|
242
|
+
f"geo={self.geometry().getRect()}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def minimize_to_tray(self):
|
|
246
|
+
"""Hide the overlay; the app stays resident in the tray.
|
|
247
|
+
|
|
248
|
+
Without a system tray there is no way to bring the overlay back, so
|
|
249
|
+
hiding would strand it unreachable; quit instead so the app does not
|
|
250
|
+
linger invisibly.
|
|
251
|
+
"""
|
|
252
|
+
if not self._tray_available:
|
|
253
|
+
self.quit()
|
|
254
|
+
return
|
|
255
|
+
self.hide()
|
|
256
|
+
|
|
257
|
+
def toggle_visibility(self):
|
|
258
|
+
"""Show the overlay if hidden, or hide it if visible."""
|
|
259
|
+
if self.isVisible():
|
|
260
|
+
# Save the exact on-screen rect before hiding. Qt's geometry()
|
|
261
|
+
# returns the pre-snap "restore" bounds for a snapped window,
|
|
262
|
+
# so we read the true displayed rect from Win32 instead. Stored
|
|
263
|
+
# as physical (left, top, right, bottom).
|
|
264
|
+
rect = win_hittest.window_rect(self)
|
|
265
|
+
if rect is None:
|
|
266
|
+
g = self.geometry()
|
|
267
|
+
dpr = self.devicePixelRatioF()
|
|
268
|
+
rect = (
|
|
269
|
+
int(g.x() * dpr),
|
|
270
|
+
int(g.y() * dpr),
|
|
271
|
+
int((g.x() + g.width()) * dpr),
|
|
272
|
+
int((g.y() + g.height()) * dpr),
|
|
273
|
+
)
|
|
274
|
+
self._pre_hide_geometry = rect
|
|
275
|
+
self.hide()
|
|
276
|
+
else:
|
|
277
|
+
self.show()
|
|
278
|
+
saved = getattr(self, "_pre_hide_geometry", None)
|
|
279
|
+
if saved is not None:
|
|
280
|
+
# Drop any maximized state so the rect is honored. Using
|
|
281
|
+
# SetWindowPlacement (not setGeometry) is essential: Windows
|
|
282
|
+
# otherwise re-applies a snapped window's stored restore
|
|
283
|
+
# position and overrides the move.
|
|
284
|
+
self.setWindowState(Qt.WindowState.WindowNoState)
|
|
285
|
+
win_hittest.set_window_rect(self, saved)
|
|
286
|
+
self._pre_hide_geometry = None
|
|
287
|
+
self._maximized = self.isMaximized()
|
|
288
|
+
self.raise_()
|
|
289
|
+
self.activateWindow()
|
|
290
|
+
|
|
291
|
+
def closeEvent(self, event):
|
|
292
|
+
# There is no close button; a WM_CLOSE (e.g. Alt+F4) should hide the
|
|
293
|
+
# overlay to the tray rather than end the app. Quitting is explicit,
|
|
294
|
+
# from the tray menu. Ignoring the event cancels the close. Without a
|
|
295
|
+
# tray there is nothing to hide to, so let the close proceed.
|
|
296
|
+
if not self._tray_available:
|
|
297
|
+
event.accept()
|
|
298
|
+
return
|
|
299
|
+
event.ignore()
|
|
300
|
+
self.hide()
|
|
301
|
+
|
|
302
|
+
def quit(self):
|
|
303
|
+
"""End the app outright, bypassing the hide-to-tray close path.
|
|
304
|
+
|
|
305
|
+
Geometry saves are debounced, so flush the pending one before the
|
|
306
|
+
event loop stops or the last move/resize would be lost on quit.
|
|
307
|
+
"""
|
|
308
|
+
if self._geometry_timer.isActive():
|
|
309
|
+
self._geometry_timer.stop()
|
|
310
|
+
self._save_geometry()
|
|
311
|
+
QApplication.quit()
|
|
312
|
+
|
|
313
|
+
def _restore_state(self):
|
|
314
|
+
"""Load the saved geometry and calibration, if any.
|
|
315
|
+
|
|
316
|
+
A saved calibration means the overlay opens already calibrated so
|
|
317
|
+
the user can read immediately without re-clicking anchors. A saved
|
|
318
|
+
geometry puts the window back where it was. Either can be absent
|
|
319
|
+
on a first run.
|
|
320
|
+
"""
|
|
321
|
+
geometry = storage.geometry(self._config_path)
|
|
322
|
+
if geometry:
|
|
323
|
+
x, y, width, height = geometry
|
|
324
|
+
self.setGeometry(x, y, width, height)
|
|
325
|
+
self._calibration = storage.calibration(self._config_path)
|
|
326
|
+
saved_format = storage.num_format(self._config_path)
|
|
327
|
+
if saved_format is not None:
|
|
328
|
+
self._num_format = saved_format
|
|
329
|
+
|
|
330
|
+
def _save_geometry(self):
|
|
331
|
+
"""Persist the current window geometry."""
|
|
332
|
+
g = self.geometry()
|
|
333
|
+
storage.save(
|
|
334
|
+
self._config_path,
|
|
335
|
+
geometry=[g.x(), g.y(), g.width(), g.height()],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def _schedule_geometry_save(self):
|
|
339
|
+
"""Debounce a geometry save triggered by a move or resize."""
|
|
340
|
+
self._geometry_timer.start()
|
|
341
|
+
|
|
342
|
+
def _save_calibration(self):
|
|
343
|
+
"""Persist the completed calibration."""
|
|
344
|
+
storage.save(self._config_path, calibration=self._calibration)
|
|
345
|
+
|
|
346
|
+
def start_calibration(self):
|
|
347
|
+
"""Begin a fresh click-and-type calibration, or restart one."""
|
|
348
|
+
self._session = CalibrationSession()
|
|
349
|
+
self._calibration = None
|
|
350
|
+
self._value_text = ""
|
|
351
|
+
self._value_error = None
|
|
352
|
+
self._caret_visible = True
|
|
353
|
+
self._copy_notice = ""
|
|
354
|
+
self._hover_active = False
|
|
355
|
+
self._hover_mode = None
|
|
356
|
+
if not self._blink_timer.isActive():
|
|
357
|
+
self._blink_timer.start()
|
|
358
|
+
self.activateWindow()
|
|
359
|
+
self.setFocus()
|
|
360
|
+
self.update()
|
|
361
|
+
|
|
362
|
+
def cancel_calibration(self):
|
|
363
|
+
"""Abandon the calibration in progress and drop all anchors."""
|
|
364
|
+
self._session = None
|
|
365
|
+
self._value_text = ""
|
|
366
|
+
self._value_error = None
|
|
367
|
+
self._blink_timer.stop()
|
|
368
|
+
self.update()
|
|
369
|
+
|
|
370
|
+
def _blink(self):
|
|
371
|
+
"""Toggle the input caret; repaint only while a value is expected."""
|
|
372
|
+
self._caret_visible = not self._caret_visible
|
|
373
|
+
if self._session is not None and self._session.expecting_value:
|
|
374
|
+
self.update()
|
|
375
|
+
|
|
376
|
+
def keyPressEvent(self, event):
|
|
377
|
+
if (
|
|
378
|
+
event.key() == Qt.Key.Key_N
|
|
379
|
+
and event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
380
|
+
):
|
|
381
|
+
self.start_calibration()
|
|
382
|
+
event.accept()
|
|
383
|
+
return
|
|
384
|
+
if self._session is not None and self._session.active:
|
|
385
|
+
self._session_key(event)
|
|
386
|
+
event.accept()
|
|
387
|
+
return
|
|
388
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
389
|
+
self.minimize_to_tray()
|
|
390
|
+
event.accept()
|
|
391
|
+
return
|
|
392
|
+
# Number keys switch the readout format when not calibrating, so
|
|
393
|
+
# the user can flip between plain, scientific, etc. without a menu.
|
|
394
|
+
idx = self._format_key_index(event.key())
|
|
395
|
+
if idx is not None:
|
|
396
|
+
self.set_num_format(OPTIONS[idx])
|
|
397
|
+
event.accept()
|
|
398
|
+
return
|
|
399
|
+
super().keyPressEvent(event)
|
|
400
|
+
|
|
401
|
+
def _format_key_index(self, key):
|
|
402
|
+
"""Map a number-key to an index into OPTIONS, or None.
|
|
403
|
+
|
|
404
|
+
Keys 1..6 (and the numpad equivalents) select the format by the
|
|
405
|
+
same number shown in the tray menu, so the menu and the keyboard
|
|
406
|
+
always agree.
|
|
407
|
+
"""
|
|
408
|
+
mapping = {
|
|
409
|
+
Qt.Key.Key_1: 0,
|
|
410
|
+
Qt.Key.Key_2: 1,
|
|
411
|
+
Qt.Key.Key_3: 2,
|
|
412
|
+
Qt.Key.Key_4: 3,
|
|
413
|
+
Qt.Key.Key_5: 4,
|
|
414
|
+
Qt.Key.Key_6: 5,
|
|
415
|
+
}
|
|
416
|
+
return mapping.get(key)
|
|
417
|
+
|
|
418
|
+
def _format_number(self, fmt):
|
|
419
|
+
"""Return the number-key that selects the given format (1-based)."""
|
|
420
|
+
try:
|
|
421
|
+
return str(OPTIONS.index(fmt) + 1)
|
|
422
|
+
except ValueError:
|
|
423
|
+
return "?"
|
|
424
|
+
|
|
425
|
+
def _session_key(self, event):
|
|
426
|
+
"""Route a key press during calibration."""
|
|
427
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
428
|
+
self.cancel_calibration()
|
|
429
|
+
return
|
|
430
|
+
if (
|
|
431
|
+
event.key() == Qt.Key.Key_Z
|
|
432
|
+
and event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
433
|
+
):
|
|
434
|
+
# Undo un-submitted text first, then a whole step.
|
|
435
|
+
if self._session.expecting_value and self._value_text:
|
|
436
|
+
self._value_text = ""
|
|
437
|
+
else:
|
|
438
|
+
self._session.undo()
|
|
439
|
+
self._value_error = None
|
|
440
|
+
self.update()
|
|
441
|
+
return
|
|
442
|
+
if event.key() == Qt.Key.Key_Backspace:
|
|
443
|
+
if self._session.expecting_value and self._value_text:
|
|
444
|
+
self._value_text = self._value_text[:-1]
|
|
445
|
+
self._value_error = None
|
|
446
|
+
self.update()
|
|
447
|
+
return
|
|
448
|
+
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
|
449
|
+
if self._session.expecting_value:
|
|
450
|
+
self._submit_value()
|
|
451
|
+
return
|
|
452
|
+
if self._session.expecting_value:
|
|
453
|
+
text = event.text()
|
|
454
|
+
if text and all(c in _VALID_CHARS for c in text):
|
|
455
|
+
if len(self._value_text) < 24:
|
|
456
|
+
self._value_text += text
|
|
457
|
+
self._value_error = None
|
|
458
|
+
self.update()
|
|
459
|
+
|
|
460
|
+
def _submit_value(self):
|
|
461
|
+
"""Commit the typed value to the current anchor point."""
|
|
462
|
+
try:
|
|
463
|
+
value = float(self._value_text)
|
|
464
|
+
except ValueError:
|
|
465
|
+
self._value_error = "That is not a number"
|
|
466
|
+
self.update()
|
|
467
|
+
return
|
|
468
|
+
self._value_text = ""
|
|
469
|
+
self._value_error = None
|
|
470
|
+
self._session.record_value(value)
|
|
471
|
+
self._maybe_finish_calibration()
|
|
472
|
+
self.update()
|
|
473
|
+
|
|
474
|
+
def _maybe_finish_calibration(self):
|
|
475
|
+
"""Promote the session's calibration to the active one, if complete.
|
|
476
|
+
|
|
477
|
+
The session is only a scaffold for collecting anchors; once a
|
|
478
|
+
Calibration exists (every axis has its values and a scale mode), we
|
|
479
|
+
switch to readout mode. Keeping the session around would suppress
|
|
480
|
+
the hover readout, so drop it.
|
|
481
|
+
"""
|
|
482
|
+
calibration = self._session.calibration()
|
|
483
|
+
if calibration is not None:
|
|
484
|
+
self._calibration = calibration
|
|
485
|
+
self._session = None
|
|
486
|
+
self._blink_timer.stop()
|
|
487
|
+
self._save_calibration()
|
|
488
|
+
|
|
489
|
+
def mousePressEvent(self, event):
|
|
490
|
+
if event.button() != Qt.MouseButton.LeftButton:
|
|
491
|
+
return super().mousePressEvent(event)
|
|
492
|
+
if not win_hittest._IS_WINDOWS:
|
|
493
|
+
# On X11 there is no WM_NCHITTEST to start a native resize, so
|
|
494
|
+
# ask Qt for one when the press lands in an edge zone. Windows
|
|
495
|
+
# keeps the Win32 shim and never reaches this point for edges.
|
|
496
|
+
edges = self._resize_edges(event.position().toPoint())
|
|
497
|
+
if edges is not None:
|
|
498
|
+
self.windowHandle().startSystemResize(edges)
|
|
499
|
+
event.accept()
|
|
500
|
+
return
|
|
501
|
+
if self._session is not None and self._session.expecting_mode:
|
|
502
|
+
# Choose linear/log for the current axis by clicking a button.
|
|
503
|
+
self._choose_mode(event.position())
|
|
504
|
+
event.accept()
|
|
505
|
+
return
|
|
506
|
+
if self._session is not None and self._session.expecting_click:
|
|
507
|
+
# Calibration clicks land on the graph beneath the overlay;
|
|
508
|
+
# make sure this window keeps focus so the typed value is
|
|
509
|
+
# captured here rather than by the underlying app.
|
|
510
|
+
self.activateWindow()
|
|
511
|
+
self.setFocus()
|
|
512
|
+
self._record_click(event)
|
|
513
|
+
self.update()
|
|
514
|
+
event.accept()
|
|
515
|
+
return
|
|
516
|
+
if self._calibration is not None:
|
|
517
|
+
# Calibration is done: a click copies the hovered readout.
|
|
518
|
+
self._copy_readout()
|
|
519
|
+
event.accept()
|
|
520
|
+
return
|
|
521
|
+
super().mousePressEvent(event)
|
|
522
|
+
|
|
523
|
+
def _copy_readout(self):
|
|
524
|
+
"""Copy the readout at the cursor as (X, Y) to the clipboard."""
|
|
525
|
+
if not self._hover_active:
|
|
526
|
+
return
|
|
527
|
+
try:
|
|
528
|
+
px, py = self._physical_from_local(self._hover_pos)
|
|
529
|
+
vx, vy = self._calibration.xy(px, py)
|
|
530
|
+
except (ValueError, ZeroDivisionError):
|
|
531
|
+
return
|
|
532
|
+
x_str = self._calibration.x.format(vx, fmt=self._num_format)
|
|
533
|
+
y_str = self._calibration.y.format(vy, fmt=self._num_format)
|
|
534
|
+
text = f"({x_str}, {y_str})"
|
|
535
|
+
QApplication.clipboard().setText(text)
|
|
536
|
+
self._copy_notice = "Copied " + text
|
|
537
|
+
self._copy_timer.start()
|
|
538
|
+
self.update()
|
|
539
|
+
|
|
540
|
+
def _clear_copy_notice(self):
|
|
541
|
+
self._copy_notice = ""
|
|
542
|
+
self.update()
|
|
543
|
+
|
|
544
|
+
def set_num_format(self, fmt):
|
|
545
|
+
"""Switch the readout number format, showing a brief confirmation.
|
|
546
|
+
|
|
547
|
+
The change is displayed for a moment via _format_notice and saved
|
|
548
|
+
to the config file so it survives a restart.
|
|
549
|
+
"""
|
|
550
|
+
if not is_valid(fmt) or fmt == self._num_format:
|
|
551
|
+
return
|
|
552
|
+
self._num_format = fmt
|
|
553
|
+
self._format_notice = "Number format: " + NAMES[fmt]
|
|
554
|
+
self._format_timer.start()
|
|
555
|
+
self._save_num_format()
|
|
556
|
+
self.update()
|
|
557
|
+
|
|
558
|
+
def _clear_format_notice(self):
|
|
559
|
+
self._format_notice = ""
|
|
560
|
+
self.update()
|
|
561
|
+
|
|
562
|
+
def _save_num_format(self):
|
|
563
|
+
storage.save(self._config_path, num_format=self._num_format)
|
|
564
|
+
|
|
565
|
+
def _record_click(self, event):
|
|
566
|
+
"""Store the clicked point in physical screen pixels."""
|
|
567
|
+
pos = event.globalPosition()
|
|
568
|
+
dpr = self.devicePixelRatioF()
|
|
569
|
+
self._session.record_point(int(pos.x() * dpr), int(pos.y() * dpr))
|
|
570
|
+
|
|
571
|
+
def _choose_mode(self, pos):
|
|
572
|
+
"""Record the linear/log choice from a click on a mode button."""
|
|
573
|
+
# The mode buttons sit one 20px row below the prompt. Reuse the
|
|
574
|
+
# layout from _paint_instruction so click targets match the
|
|
575
|
+
# drawn buttons.
|
|
576
|
+
rects = self._mode_option_rects(self._mode_buttons_top())
|
|
577
|
+
for name, rect in rects.items():
|
|
578
|
+
if rect.contains(pos.toPoint()):
|
|
579
|
+
self._session.record_mode(name)
|
|
580
|
+
self._hover_mode = None
|
|
581
|
+
self._maybe_finish_calibration()
|
|
582
|
+
self.update()
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
def _local_from_physical(self, px, py):
|
|
586
|
+
"""Map a physical screen point back to local logical coordinates."""
|
|
587
|
+
dpr = self.devicePixelRatioF()
|
|
588
|
+
logical = QPoint(int(px / dpr), int(py / dpr))
|
|
589
|
+
return self.mapFromGlobal(logical)
|
|
590
|
+
|
|
591
|
+
def _physical_from_local(self, local):
|
|
592
|
+
"""Map a local logical point to physical screen pixels."""
|
|
593
|
+
dpr = self.devicePixelRatioF()
|
|
594
|
+
global_ = self.mapToGlobal(local)
|
|
595
|
+
return int(global_.x() * dpr), int(global_.y() * dpr)
|
|
596
|
+
|
|
597
|
+
def hit_test_code(self, local):
|
|
598
|
+
"""Classify a local point for Win32 hit testing.
|
|
599
|
+
|
|
600
|
+
Returns an HT* value telling Windows what this part of the
|
|
601
|
+
window is for: borders resize natively, the title bar is a real
|
|
602
|
+
caption (drag + snap), and everything else is ordinary client
|
|
603
|
+
area that Qt receives.
|
|
604
|
+
"""
|
|
605
|
+
w, h = self.width(), self.height()
|
|
606
|
+
x, y = local.x(), local.y()
|
|
607
|
+
if not self.rect().contains(local):
|
|
608
|
+
return win_hittest.HTCLIENT
|
|
609
|
+
if x < _RESIZE_ZONE and y < _RESIZE_ZONE:
|
|
610
|
+
return win_hittest.HTTOPLEFT
|
|
611
|
+
if x >= w - _RESIZE_ZONE and y < _RESIZE_ZONE:
|
|
612
|
+
return win_hittest.HTTOPRIGHT
|
|
613
|
+
if x < _RESIZE_ZONE and y >= h - _RESIZE_ZONE:
|
|
614
|
+
return win_hittest.HTBOTTOMLEFT
|
|
615
|
+
if x >= w - _RESIZE_ZONE and y >= h - _RESIZE_ZONE:
|
|
616
|
+
return win_hittest.HTBOTTOMRIGHT
|
|
617
|
+
if y < _RESIZE_ZONE:
|
|
618
|
+
return win_hittest.HTTOP
|
|
619
|
+
if y >= h - _RESIZE_ZONE:
|
|
620
|
+
return win_hittest.HTBOTTOM
|
|
621
|
+
if x < _RESIZE_ZONE:
|
|
622
|
+
return win_hittest.HTLEFT
|
|
623
|
+
if x >= w - _RESIZE_ZONE:
|
|
624
|
+
return win_hittest.HTRIGHT
|
|
625
|
+
if y < TITLEBAR_HEIGHT:
|
|
626
|
+
if self.title_bar.is_over_buttons(local):
|
|
627
|
+
return win_hittest.HTCLIENT
|
|
628
|
+
return win_hittest.HTCAPTION
|
|
629
|
+
return win_hittest.HTCLIENT
|
|
630
|
+
|
|
631
|
+
def _resize_edges(self, local):
|
|
632
|
+
"""Return the Qt.Edges to resize for a local point, or None.
|
|
633
|
+
|
|
634
|
+
Used on non-Windows, where a frameless window has no WM_NCHITTEST to
|
|
635
|
+
hand edge drags to the OS. Mirrors hit_test_code's edge zones so
|
|
636
|
+
clicking-and-dragging a border resizes the overlay instead of being
|
|
637
|
+
treated as a click on the graph. Corners combine two edges. The top
|
|
638
|
+
strip is the TitleBar's and is handled there as a move/resize.
|
|
639
|
+
"""
|
|
640
|
+
w, h = self.width(), self.height()
|
|
641
|
+
x, y = local.x(), local.y()
|
|
642
|
+
if not self.rect().contains(local) or y < TITLEBAR_HEIGHT:
|
|
643
|
+
return None
|
|
644
|
+
edges = Qt.Edges()
|
|
645
|
+
if x < _RESIZE_ZONE:
|
|
646
|
+
edges |= Qt.Edge.LeftEdge
|
|
647
|
+
elif x >= w - _RESIZE_ZONE:
|
|
648
|
+
edges |= Qt.Edge.RightEdge
|
|
649
|
+
if y >= h - _RESIZE_ZONE:
|
|
650
|
+
edges |= Qt.Edge.BottomEdge
|
|
651
|
+
return edges or None
|
|
652
|
+
|
|
653
|
+
def nativeEvent(self, event_type, message):
|
|
654
|
+
result = win_hittest.handle_native_event(self, event_type, message)
|
|
655
|
+
if DEBUG and result[0]:
|
|
656
|
+
msg = win_hittest.wintypes.MSG.from_address(int(message))
|
|
657
|
+
_dbg(f"nativeEvent msg=0x{msg.message:04x} handled={result[1]}")
|
|
658
|
+
return result
|
|
659
|
+
|
|
660
|
+
def mouseMoveEvent(self, event):
|
|
661
|
+
if DEBUG and event.buttons():
|
|
662
|
+
pos = event.position().toPoint()
|
|
663
|
+
_dbg(f"mouseMove (dragging) at {pos.x()},{pos.y()}")
|
|
664
|
+
# Track the cursor. While picking a calibration point the live
|
|
665
|
+
# guide line follows it; with a calibration done the hover readout
|
|
666
|
+
# crosshair does. Repaint only when the position changes so the
|
|
667
|
+
# guide does not shimmer on every mouse move.
|
|
668
|
+
old = self._hover_pos
|
|
669
|
+
self._hover_pos = event.position().toPoint()
|
|
670
|
+
self._hover_active = self._calibration is not None
|
|
671
|
+
# Track which mode button is under the cursor so it can brighten.
|
|
672
|
+
mode_hover = None
|
|
673
|
+
if self._session is not None and self._session.expecting_mode:
|
|
674
|
+
for name, rect in self._mode_option_rects(
|
|
675
|
+
self._mode_buttons_top()
|
|
676
|
+
).items():
|
|
677
|
+
if rect.contains(self._hover_pos):
|
|
678
|
+
mode_hover = name
|
|
679
|
+
break
|
|
680
|
+
active = self._hover_active or (
|
|
681
|
+
self._session is not None and self._session.expecting_click
|
|
682
|
+
)
|
|
683
|
+
if (active and self._hover_pos != old) or (
|
|
684
|
+
mode_hover != self._hover_mode
|
|
685
|
+
):
|
|
686
|
+
self._hover_mode = mode_hover
|
|
687
|
+
self.update()
|
|
688
|
+
super().mouseMoveEvent(event)
|
|
689
|
+
|
|
690
|
+
def leaveEvent(self, event):
|
|
691
|
+
if self._hover_active:
|
|
692
|
+
self._hover_active = False
|
|
693
|
+
self.update()
|
|
694
|
+
if self._hover_mode:
|
|
695
|
+
self._hover_mode = None
|
|
696
|
+
self.update()
|
|
697
|
+
super().leaveEvent(event)
|
|
698
|
+
|
|
699
|
+
def mouseReleaseEvent(self, event):
|
|
700
|
+
pos = event.position().toPoint()
|
|
701
|
+
_dbg(f"mouseRelease at {pos.x()},{pos.y()}")
|
|
702
|
+
super().mouseReleaseEvent(event)
|
|
703
|
+
|
|
704
|
+
def changeEvent(self, event):
|
|
705
|
+
super().changeEvent(event)
|
|
706
|
+
if event.type() == QEvent.Type.WindowStateChange:
|
|
707
|
+
# A native maximize (drag-to-top-edge snap) sets the real
|
|
708
|
+
# window state behind our back; mirror it so the button glyph
|
|
709
|
+
# stays honest.
|
|
710
|
+
self._maximized = self.isMaximized()
|
|
711
|
+
if DEBUG:
|
|
712
|
+
_dbg(
|
|
713
|
+
f"changeEvent ws={self.windowState()} "
|
|
714
|
+
f"max={self.isMaximized()}"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
def moveEvent(self, event):
|
|
718
|
+
super().moveEvent(event)
|
|
719
|
+
# Dragging a manually-maximized window restores its normal shape;
|
|
720
|
+
# notice and clear the flag so the next toggle maximizes again.
|
|
721
|
+
if (
|
|
722
|
+
self._maximized
|
|
723
|
+
and self.geometry() != self.screen().availableGeometry()
|
|
724
|
+
):
|
|
725
|
+
self._maximized = False
|
|
726
|
+
_dbg("cleared _maximized after drag")
|
|
727
|
+
# While Windows drags the window it moves the painted surface as a
|
|
728
|
+
# whole, so screen-anchored calibration markers would stay glued
|
|
729
|
+
# to the old position. Force a repaint so they recompute to their
|
|
730
|
+
# true screen positions.
|
|
731
|
+
self.update()
|
|
732
|
+
self._schedule_geometry_save()
|
|
733
|
+
|
|
734
|
+
def paintEvent(self, event):
|
|
735
|
+
painter = QPainter(self)
|
|
736
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
737
|
+
# A faint reddish tint so the window is perceivable over both
|
|
738
|
+
# white and black backgrounds, while the graph underneath still
|
|
739
|
+
# shows through.
|
|
740
|
+
painter.fillRect(self.rect(), QColor(255, 60, 60, 26))
|
|
741
|
+
# A clear red border so the user can see where the overlay is
|
|
742
|
+
# and grab it to resize, on light and dark backgrounds alike.
|
|
743
|
+
painter.setPen(QPen(QColor(255, 90, 90, 240), 2))
|
|
744
|
+
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
|
|
745
|
+
if self._calibration is not None:
|
|
746
|
+
self._paint_calibration_region(painter)
|
|
747
|
+
self._paint_readout(painter)
|
|
748
|
+
if self._session is not None:
|
|
749
|
+
self._paint_calibration(painter)
|
|
750
|
+
|
|
751
|
+
def _paint_calibration(self, painter):
|
|
752
|
+
self._paint_anchors(painter)
|
|
753
|
+
self._paint_live_guide(painter)
|
|
754
|
+
self._paint_instruction(painter)
|
|
755
|
+
|
|
756
|
+
def _paint_live_guide(self, painter):
|
|
757
|
+
"""Draw a guide line that follows the cursor while a point is
|
|
758
|
+
being picked, in the current axis direction.
|
|
759
|
+
|
|
760
|
+
This lets the user align the click with the graph's gridline: a
|
|
761
|
+
vertical line while picking an X point, a horizontal one for Y.
|
|
762
|
+
It fades away once a value is being typed, since the point is
|
|
763
|
+
already placed.
|
|
764
|
+
"""
|
|
765
|
+
if not self._session.expecting_click:
|
|
766
|
+
return
|
|
767
|
+
axis = self._session.current_axis
|
|
768
|
+
color = _X_COLOR if axis == "x" else _Y_COLOR
|
|
769
|
+
pen = QPen(color, 1)
|
|
770
|
+
pen.setStyle(Qt.PenStyle.DashLine)
|
|
771
|
+
painter.setPen(pen)
|
|
772
|
+
if axis == "x":
|
|
773
|
+
painter.drawLine(
|
|
774
|
+
QPoint(self._hover_pos.x(), TITLEBAR_HEIGHT),
|
|
775
|
+
QPoint(self._hover_pos.x(), self.height()),
|
|
776
|
+
)
|
|
777
|
+
else:
|
|
778
|
+
painter.drawLine(
|
|
779
|
+
QPoint(0, self._hover_pos.y()),
|
|
780
|
+
QPoint(self.width(), self._hover_pos.y()),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def _paint_anchors(self, painter):
|
|
784
|
+
"""Draw the placed anchors as a dot with a guide line in the axis
|
|
785
|
+
direction.
|
|
786
|
+
|
|
787
|
+
Each axis only cares about one coordinate: an X anchor marks the
|
|
788
|
+
pixel's horizontal position, a Y anchor the vertical. Draw just
|
|
789
|
+
the guide line along that axis (not a small cross) so the marker
|
|
790
|
+
reads as a gridline crossing, not a cursor.
|
|
791
|
+
"""
|
|
792
|
+
for axis, _index, px, py, value in self._session.anchors():
|
|
793
|
+
local = self._local_from_physical(px, py)
|
|
794
|
+
color = _X_COLOR if axis == "x" else _Y_COLOR
|
|
795
|
+
x, y = local.x(), local.y()
|
|
796
|
+
if axis == "x":
|
|
797
|
+
painter.drawLine(
|
|
798
|
+
QPoint(x, TITLEBAR_HEIGHT), QPoint(x, self.height())
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
painter.drawLine(QPoint(0, y), QPoint(self.width(), y))
|
|
802
|
+
painter.setPen(QPen(color, 2))
|
|
803
|
+
painter.drawEllipse(local, 4, 4)
|
|
804
|
+
if value is not None:
|
|
805
|
+
self._draw_anchor_label(painter, local, color, str(value))
|
|
806
|
+
|
|
807
|
+
def _draw_anchor_label(self, painter, local, color, text):
|
|
808
|
+
"""Draw an anchor's value beside its marker, translucent with a
|
|
809
|
+
dark halo so it reads over whatever graph is beneath."""
|
|
810
|
+
font = QFont()
|
|
811
|
+
font.setPointSize(9)
|
|
812
|
+
font.setBold(True)
|
|
813
|
+
painter.setFont(font)
|
|
814
|
+
self._draw_outlined_text(
|
|
815
|
+
painter,
|
|
816
|
+
text,
|
|
817
|
+
QPointF(local.x() + 10, local.y()),
|
|
818
|
+
color,
|
|
819
|
+
_TEXT_MEDIUM,
|
|
820
|
+
bold=True,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
def _draw_outlined_text(
|
|
824
|
+
self, painter, text, pos, color, size, bold, centered=False
|
|
825
|
+
):
|
|
826
|
+
"""Draw translucent text with a dark outline so it stays legible
|
|
827
|
+
over any background without an opaque backing box.
|
|
828
|
+
|
|
829
|
+
The graph shows through the semi-transparent glyphs, but the dark
|
|
830
|
+
halo keeps the text readable on light or dark content.
|
|
831
|
+
|
|
832
|
+
When centered is True the text is centered on pos.x() rather than
|
|
833
|
+
starting there, so it can be centered inside a button or label.
|
|
834
|
+
"""
|
|
835
|
+
painter.save()
|
|
836
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
837
|
+
font = QFont()
|
|
838
|
+
font.setPointSize(size)
|
|
839
|
+
font.setBold(bold)
|
|
840
|
+
painter.setFont(font)
|
|
841
|
+
metrics = QFontMetricsF(font)
|
|
842
|
+
height = metrics.height()
|
|
843
|
+
baseline = pos.y() + height / 2
|
|
844
|
+
x = pos.x() - (metrics.horizontalAdvance(text) / 2 if centered else 0)
|
|
845
|
+
# Dark halo: draw the text offset in a ring around the glyph, then
|
|
846
|
+
# the colored glyph on top. Offsets are symmetric on all four
|
|
847
|
+
# sides so the outline reads evenly, not just left and right. This
|
|
848
|
+
# reads as a soft outline, not a box, and stays legible on light
|
|
849
|
+
# or dark content.
|
|
850
|
+
halo_color = QColor(8, 8, 8, 220)
|
|
851
|
+
painter.setPen(halo_color)
|
|
852
|
+
for dx in (-2, -1, 0, 1, 2):
|
|
853
|
+
for dy in (-2, -1, 0, 1, 2):
|
|
854
|
+
if dx == 0 and dy == 0:
|
|
855
|
+
continue
|
|
856
|
+
painter.drawText(QPointF(x + dx, baseline + dy), text)
|
|
857
|
+
painter.setPen(color)
|
|
858
|
+
painter.drawText(QPointF(x, baseline), text)
|
|
859
|
+
painter.restore()
|
|
860
|
+
|
|
861
|
+
def _mode_option_rects(self, row_top):
|
|
862
|
+
"""Return the linear/log button rectangles for the given row top.
|
|
863
|
+
|
|
864
|
+
Two rounded translucent buttons sit side by side below the prompt
|
|
865
|
+
while the session is asking for a linear/log choice. The geometry
|
|
866
|
+
is computed here and shared by painting and hit-testing so the
|
|
867
|
+
drawn buttons and the click targets always agree.
|
|
868
|
+
"""
|
|
869
|
+
button_w = 96
|
|
870
|
+
button_h = 28
|
|
871
|
+
gap = 16
|
|
872
|
+
left = _EDGE + 8
|
|
873
|
+
width = self.width() - left * 2
|
|
874
|
+
start_x = left + (width - (button_w * 2 + gap)) // 2
|
|
875
|
+
return {
|
|
876
|
+
"lin": QRect(start_x, row_top, button_w, button_h),
|
|
877
|
+
"log": QRect(
|
|
878
|
+
start_x + button_w + gap, row_top, button_w, button_h
|
|
879
|
+
),
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
def _mode_buttons_top(self):
|
|
883
|
+
"""The y of the mode-button row during the linear/log choice.
|
|
884
|
+
|
|
885
|
+
During a mode choice the block is just the prompt plus the buttons
|
|
886
|
+
(the value-input and error rows are never shown), so it is
|
|
887
|
+
bottom-aligned as two rows; the buttons occupy the second row.
|
|
888
|
+
This matches the painting in _paint_instruction so the click
|
|
889
|
+
targets and the drawn buttons agree.
|
|
890
|
+
"""
|
|
891
|
+
return (
|
|
892
|
+
self.height()
|
|
893
|
+
- _INSTRUCTION_BOTTOM_MARGIN
|
|
894
|
+
- 2 * _INSTRUCTION_ROW_H
|
|
895
|
+
+ _INSTRUCTION_ROW_H
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
def _paint_instruction(self, painter):
|
|
899
|
+
"""Draw the calibration prompt and hints as floating translucent
|
|
900
|
+
text pinned low in the window, with no backing box.
|
|
901
|
+
|
|
902
|
+
The rows read in order from the instruction down: prompt, then the
|
|
903
|
+
value input (or mode buttons, or error). The block is placed so its
|
|
904
|
+
last row sits a margin above the bottom edge, keeping the whole
|
|
905
|
+
instruction group near the bottom without any row clipping.
|
|
906
|
+
"""
|
|
907
|
+
left = _EDGE + 8
|
|
908
|
+
width = self.width() - left * 2
|
|
909
|
+
prompt = self._session.prompt()
|
|
910
|
+
if self._session.active:
|
|
911
|
+
title_color = QColor(255, 255, 255)
|
|
912
|
+
else:
|
|
913
|
+
title_color = QColor(140, 230, 150)
|
|
914
|
+
|
|
915
|
+
# Count the rows that will be drawn so the block can be placed
|
|
916
|
+
# bottom-aligned: prompt always, then the conditional rows.
|
|
917
|
+
rows = 1
|
|
918
|
+
if self._session.expecting_value:
|
|
919
|
+
rows += 1
|
|
920
|
+
if self._session.expecting_mode:
|
|
921
|
+
rows += 1
|
|
922
|
+
if self._value_error:
|
|
923
|
+
rows += 1
|
|
924
|
+
top = (
|
|
925
|
+
self.height()
|
|
926
|
+
- _INSTRUCTION_BOTTOM_MARGIN
|
|
927
|
+
- rows * _INSTRUCTION_ROW_H
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
# Prompt on the left and the keyboard hint on the right, on the same
|
|
931
|
+
# baseline so the instruction block reads as one row.
|
|
932
|
+
self._draw_outlined_text(
|
|
933
|
+
painter,
|
|
934
|
+
prompt,
|
|
935
|
+
QPointF(left, top),
|
|
936
|
+
title_color,
|
|
937
|
+
_TEXT_LARGE,
|
|
938
|
+
bold=True,
|
|
939
|
+
)
|
|
940
|
+
hint_text = (
|
|
941
|
+
"Ctrl+Z undo · Esc cancel"
|
|
942
|
+
if self._session.active
|
|
943
|
+
else "Ctrl+N redo · Esc hide"
|
|
944
|
+
)
|
|
945
|
+
self._draw_outlined_text(
|
|
946
|
+
painter,
|
|
947
|
+
hint_text,
|
|
948
|
+
QPointF(left + width - 220, top),
|
|
949
|
+
QColor(235, 235, 235),
|
|
950
|
+
_TEXT_LARGE,
|
|
951
|
+
bold=True,
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
# Interactive rows below the prompt, most recent nearest it.
|
|
955
|
+
row_y = top + _INSTRUCTION_ROW_H
|
|
956
|
+
if self._session.expecting_value:
|
|
957
|
+
self._draw_value_input(painter, left, row_y, width)
|
|
958
|
+
row_y += _INSTRUCTION_ROW_H
|
|
959
|
+
if self._session.expecting_mode:
|
|
960
|
+
self._draw_mode_buttons(painter, row_y)
|
|
961
|
+
row_y += _INSTRUCTION_ROW_H
|
|
962
|
+
if self._value_error:
|
|
963
|
+
self._draw_outlined_text(
|
|
964
|
+
painter,
|
|
965
|
+
self._value_error,
|
|
966
|
+
QPointF(left, row_y),
|
|
967
|
+
QColor(255, 130, 130),
|
|
968
|
+
_TEXT_MEDIUM,
|
|
969
|
+
bold=True,
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
def _draw_value_input(self, painter, left, top, width):
|
|
973
|
+
"""Draw the typed value and a blinking caret on the input line."""
|
|
974
|
+
self._draw_outlined_text(
|
|
975
|
+
painter,
|
|
976
|
+
self._value_text or " ",
|
|
977
|
+
QPointF(left, top),
|
|
978
|
+
QColor(255, 255, 255),
|
|
979
|
+
_TEXT_LARGE,
|
|
980
|
+
bold=True,
|
|
981
|
+
)
|
|
982
|
+
if self._caret_visible:
|
|
983
|
+
font = QFont()
|
|
984
|
+
font.setPointSize(_TEXT_LARGE)
|
|
985
|
+
font.setBold(True)
|
|
986
|
+
painter.setFont(font)
|
|
987
|
+
metrics = QFontMetricsF(font)
|
|
988
|
+
caret_x = left + metrics.horizontalAdvance(self._value_text) + 2
|
|
989
|
+
baseline = top + metrics.height() / 2
|
|
990
|
+
painter.setPen(QPen(QColor(255, 255, 255, 220), 2))
|
|
991
|
+
painter.drawLine(
|
|
992
|
+
QPointF(caret_x, baseline - 9),
|
|
993
|
+
QPointF(caret_x, baseline + 9),
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def _draw_mode_buttons(self, painter, row_top):
|
|
997
|
+
"""Draw the linear/log choice buttons for the current axis."""
|
|
998
|
+
rects = self._mode_option_rects(row_top)
|
|
999
|
+
self._draw_mode_button(painter, rects["lin"], "Linear", "lin")
|
|
1000
|
+
self._draw_mode_button(painter, rects["log"], "Log", "log")
|
|
1001
|
+
|
|
1002
|
+
def _draw_mode_button(self, painter, rect, label, name):
|
|
1003
|
+
"""Draw one mode button as a translucent rounded rect with a dark
|
|
1004
|
+
halo outline and a faint fill that brightens when hovered, so the
|
|
1005
|
+
graph shows through and it reads as a button rather than a box.
|
|
1006
|
+
|
|
1007
|
+
The halo is drawn in the halo color around the label so the text
|
|
1008
|
+
stays legible over any content; the label itself is colored by the
|
|
1009
|
+
axis hue so the choice feels tied to the axis being scaled.
|
|
1010
|
+
"""
|
|
1011
|
+
painter.save()
|
|
1012
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
1013
|
+
hovered = self._hover_mode == name
|
|
1014
|
+
# A faint fill so the button has a tangible extent over a busy
|
|
1015
|
+
# graph, using the axis hue but heavily faded so it stays
|
|
1016
|
+
# translucent and does not obscure what is behind it.
|
|
1017
|
+
axis = self._session.current_axis
|
|
1018
|
+
base = _X_COLOR if axis == "x" else _Y_COLOR
|
|
1019
|
+
fill = QColor(
|
|
1020
|
+
base.red(), base.green(), base.blue(), 60 if not hovered else 110
|
|
1021
|
+
)
|
|
1022
|
+
outline = QColor(
|
|
1023
|
+
base.red(), base.green(), base.blue(), 200 if hovered else 140
|
|
1024
|
+
)
|
|
1025
|
+
painter.setPen(QPen(outline, 1))
|
|
1026
|
+
painter.setBrush(fill)
|
|
1027
|
+
painter.drawRoundedRect(rect, 6, 6)
|
|
1028
|
+
painter.restore()
|
|
1029
|
+
self._draw_outlined_text(
|
|
1030
|
+
painter,
|
|
1031
|
+
label,
|
|
1032
|
+
QPointF(rect.center().x(), rect.center().y()),
|
|
1033
|
+
QColor(240, 240, 240),
|
|
1034
|
+
_TEXT_MEDIUM,
|
|
1035
|
+
bold=True,
|
|
1036
|
+
centered=True,
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
def _paint_calibration_region(self, painter):
|
|
1040
|
+
"""Draw the calibrated screen region as a guide rectangle.
|
|
1041
|
+
|
|
1042
|
+
The rectangle is anchored to absolute screen coordinates, not the
|
|
1043
|
+
window, so if the graph underneath is moved the box stays where it
|
|
1044
|
+
was calibrated and the misalignment is easy to spot. Drawn dashed
|
|
1045
|
+
like the calibration guides and in a green distinct from the axis
|
|
1046
|
+
hues, so it stands out against black gridlines.
|
|
1047
|
+
"""
|
|
1048
|
+
left, top, right, bottom = self._calibration.region()
|
|
1049
|
+
p0 = self._local_from_physical(left, top)
|
|
1050
|
+
p1 = self._local_from_physical(right, bottom)
|
|
1051
|
+
rect = QRect(p0, p1)
|
|
1052
|
+
pen = QPen(_REGION_COLOR, 1)
|
|
1053
|
+
pen.setStyle(Qt.PenStyle.DashLine)
|
|
1054
|
+
painter.setPen(pen)
|
|
1055
|
+
painter.drawRect(rect)
|
|
1056
|
+
|
|
1057
|
+
def _paint_readout(self, painter):
|
|
1058
|
+
"""Draw the crosshair and (X, Y) readout at the hover position.
|
|
1059
|
+
|
|
1060
|
+
The crosshair and readout only appear once a calibration exists.
|
|
1061
|
+
The values come from the mouse's physical screen position, so the
|
|
1062
|
+
readout is independent of where the overlay sits.
|
|
1063
|
+
"""
|
|
1064
|
+
if not self._hover_active or self._session is not None:
|
|
1065
|
+
return
|
|
1066
|
+
px, py = self._physical_from_local(self._hover_pos)
|
|
1067
|
+
try:
|
|
1068
|
+
vx, vy = self._calibration.xy(px, py)
|
|
1069
|
+
except (ValueError, ZeroDivisionError):
|
|
1070
|
+
return
|
|
1071
|
+
x_text = self._calibration.x.format(vx, fmt=self._num_format)
|
|
1072
|
+
y_text = self._calibration.y.format(vy, fmt=self._num_format)
|
|
1073
|
+
|
|
1074
|
+
# Crosshair: two translucent lines through the cursor, snapped to
|
|
1075
|
+
# the graph pixel so the readout lines up with it. Drawn with a
|
|
1076
|
+
# dark underline so they read as a clear hairline over any content.
|
|
1077
|
+
x = self._hover_pos.x()
|
|
1078
|
+
y = self._hover_pos.y()
|
|
1079
|
+
for color, ox, oy in (
|
|
1080
|
+
(QColor(8, 8, 8, 160), 1, 0),
|
|
1081
|
+
(QColor(8, 8, 8, 160), -1, 0),
|
|
1082
|
+
(QColor(8, 8, 8, 160), 0, 1),
|
|
1083
|
+
(QColor(8, 8, 8, 160), 0, -1),
|
|
1084
|
+
(QColor(255, 255, 255, 110), 0, 0),
|
|
1085
|
+
):
|
|
1086
|
+
painter.setPen(QPen(color, 1))
|
|
1087
|
+
painter.drawLine(QPoint(0, y + oy), QPoint(self.width(), y + oy))
|
|
1088
|
+
painter.drawLine(
|
|
1089
|
+
QPoint(x + ox, TITLEBAR_HEIGHT), QPoint(x + ox, self.height())
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
# Readout text near the cursor, offset below-right so it does not
|
|
1093
|
+
# cover the point being read.
|
|
1094
|
+
readout = f"({x_text}, {y_text})"
|
|
1095
|
+
self._draw_outlined_text(
|
|
1096
|
+
painter,
|
|
1097
|
+
readout,
|
|
1098
|
+
QPointF(x + 14, y + 14),
|
|
1099
|
+
QColor(255, 255, 255),
|
|
1100
|
+
_TEXT_LARGE,
|
|
1101
|
+
bold=True,
|
|
1102
|
+
)
|
|
1103
|
+
# A small indicator showing the active number format and the number
|
|
1104
|
+
# key that selects it, plus any transient format-change notice. This
|
|
1105
|
+
# keeps the user aware of which format is shown since some formats
|
|
1106
|
+
# (plain vs scientific for a rounded value) can look alike.
|
|
1107
|
+
info = self._format_notice or (
|
|
1108
|
+
"Format "
|
|
1109
|
+
+ self._format_number(self._num_format)
|
|
1110
|
+
+ " ("
|
|
1111
|
+
+ NAMES[self._num_format]
|
|
1112
|
+
+ ")"
|
|
1113
|
+
)
|
|
1114
|
+
self._draw_outlined_text(
|
|
1115
|
+
painter,
|
|
1116
|
+
info,
|
|
1117
|
+
QPointF(x + 14, y + 40),
|
|
1118
|
+
QColor(220, 220, 220),
|
|
1119
|
+
_TEXT_SMALL,
|
|
1120
|
+
bold=True,
|
|
1121
|
+
)
|
|
1122
|
+
if self._copy_notice:
|
|
1123
|
+
self._draw_outlined_text(
|
|
1124
|
+
painter,
|
|
1125
|
+
self._copy_notice,
|
|
1126
|
+
QPointF(x + 14, y + 64),
|
|
1127
|
+
QColor(150, 235, 160),
|
|
1128
|
+
_TEXT_SMALL,
|
|
1129
|
+
bold=True,
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
def resizeEvent(self, event):
|
|
1133
|
+
self.title_bar.setGeometry(0, 0, self.width(), TITLEBAR_HEIGHT)
|
|
1134
|
+
self._schedule_geometry_save()
|
|
1135
|
+
super().resizeEvent(event)
|