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