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/settings.py ADDED
@@ -0,0 +1,130 @@
1
+ """Settings dialog for capturing a new global hotkey.
2
+
3
+ A small modal dialog that lets the user press a key combination and
4
+ records it. The overlay is a frameless custom window, but this dialog is
5
+ a conventional temporary window — it only exists to capture input, then
6
+ closes, so a native widget is appropriate here.
7
+ """
8
+
9
+ from PySide6.QtCore import Qt
10
+ from PySide6.QtWidgets import QDialog, QLabel, QPushButton, QVBoxLayout
11
+
12
+ from .hotkey import KeyCombo, qkey_to_vk, qmodifiers_to_names
13
+
14
+ # Keys that should not be treated as the shortcut by themselves.
15
+ _MODIFIER_KEYS = {
16
+ Qt.Key.Key_Control,
17
+ Qt.Key.Key_Shift,
18
+ Qt.Key.Key_Alt,
19
+ Qt.Key.Key_Meta,
20
+ Qt.Key.Key_Super_L,
21
+ Qt.Key.Key_Super_R,
22
+ Qt.Key.Key_unknown,
23
+ }
24
+
25
+
26
+ class HotkeyDialog(QDialog):
27
+ """A dialog that captures the next key combination the user presses."""
28
+
29
+ def __init__(self, current=None, parent=None):
30
+ super().__init__(parent)
31
+ self.setWindowTitle("Set Global Hotkey")
32
+ self.setModal(False)
33
+ self._combo = None
34
+
35
+ layout = QVBoxLayout(self)
36
+ self._prompt = QLabel(
37
+ "Press a key combination (e.g. Win+Alt+P)\n\n"
38
+ "include a modifier key plus a letter. "
39
+ "Esc to cancel."
40
+ )
41
+ self._prompt.setWordWrap(True)
42
+ layout.addWidget(self._prompt)
43
+
44
+ self._combo_label = QLabel(self._describe(current))
45
+ self._combo_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
46
+ self._combo_label.setStyleSheet(
47
+ "font-size: 16px; font-weight: bold; padding: 12px;"
48
+ )
49
+ layout.addWidget(self._combo_label)
50
+
51
+ if current is not None:
52
+ self._combo = current
53
+
54
+ buttons = QVBoxLayout()
55
+ use_btn = QPushButton("Use This Key")
56
+ use_btn.clicked.connect(self._use_current)
57
+ buttons.addWidget(use_btn)
58
+ cancel_btn = QPushButton("Cancel")
59
+ cancel_btn.clicked.connect(self.reject)
60
+ buttons.addWidget(cancel_btn)
61
+ layout.addLayout(buttons)
62
+
63
+ def _describe(self, combo):
64
+ return combo.text() if combo is not None else "— none —"
65
+
66
+ def keyPressEvent(self, event):
67
+ key = event.key()
68
+ if key in (Qt.Key.Key_Escape, Qt.Key.Key_Cancel):
69
+ self.reject()
70
+ return
71
+ if key in _MODIFIER_KEYS:
72
+ # A bare modifier isn't a shortcut; keep waiting.
73
+ return
74
+ vk = qkey_to_vk(key)
75
+ if vk == 0:
76
+ self._show_hint("That key isn't supported")
77
+ return
78
+ modifiers = qmodifiers_to_names(event.modifiers())
79
+ if not modifiers:
80
+ self._show_hint("Include a modifier key (Ctrl, Alt, or Win)")
81
+ return
82
+ self._combo = KeyCombo(modifiers, self._key_name(key), vk)
83
+ self._combo_label.setText(self._describe(self._combo))
84
+
85
+ def _show_hint(self, text):
86
+ self._prompt.setText(text)
87
+ self._prompt.setStyleSheet("color: #c0392b;")
88
+
89
+ def _key_name(self, key):
90
+ """Return a readable name for a Qt key."""
91
+ # Qt.Key.Key_A .. Key_Z have the same value as their ASCII letter.
92
+ if 0x41 <= key <= 0x5A:
93
+ return chr(key)
94
+ if 0x30 <= key <= 0x39:
95
+ return chr(key)
96
+ names = {
97
+ Qt.Key.Key_Space: "Space",
98
+ Qt.Key.Key_Tab: "Tab",
99
+ Qt.Key.Key_Left: "Left",
100
+ Qt.Key.Key_Right: "Right",
101
+ Qt.Key.Key_Up: "Up",
102
+ Qt.Key.Key_Down: "Down",
103
+ Qt.Key.Key_Home: "Home",
104
+ Qt.Key.Key_End: "End",
105
+ Qt.Key.Key_PageUp: "Page Up",
106
+ Qt.Key.Key_PageDown: "Page Down",
107
+ Qt.Key.Key_Insert: "Insert",
108
+ Qt.Key.Key_Delete: "Delete",
109
+ Qt.Key.Key_F1: "F1",
110
+ Qt.Key.Key_F2: "F2",
111
+ Qt.Key.Key_F3: "F3",
112
+ Qt.Key.Key_F4: "F4",
113
+ Qt.Key.Key_F5: "F5",
114
+ Qt.Key.Key_F6: "F6",
115
+ Qt.Key.Key_F7: "F7",
116
+ Qt.Key.Key_F8: "F8",
117
+ Qt.Key.Key_F9: "F9",
118
+ Qt.Key.Key_F10: "F10",
119
+ Qt.Key.Key_F11: "F11",
120
+ Qt.Key.Key_F12: "F12",
121
+ }
122
+ return names.get(key, "Key")
123
+
124
+ def _use_current(self):
125
+ if self._combo is not None:
126
+ self.accept()
127
+
128
+ def combo(self):
129
+ """Return the recorded KeyCombo, or None."""
130
+ return self._combo
plotruler/storage.py ADDED
@@ -0,0 +1,144 @@
1
+ """Persist a calibration and the window position to disk.
2
+
3
+ This module is deliberately free of any Qt imports so it stays
4
+ unit-testable and portable, following the same rule as the math core.
5
+ It stores a Calibration (and optional window geometry) as JSON in a
6
+ user config file; the overlay layer decides where that file lives,
7
+ so this module only deals with the data shape.
8
+
9
+ The config file is a small JSON object:
10
+
11
+ {
12
+ "geometry": [x, y, width, height],
13
+ "calibration": {
14
+ "x": {"p1": ..., "v1": ..., "p2": ..., "v2": ..., "log": false},
15
+ "y": {"p1": ..., "v1": ..., "p2": ..., "v2": ..., "log": false}
16
+ }
17
+ }
18
+ """
19
+
20
+ import json
21
+ import os
22
+
23
+ from .core import AxisCalibration, Calibration
24
+
25
+
26
+ def calibration_to_dict(calibration):
27
+ """Return a Calibration as a nested dict."""
28
+ return {
29
+ "x": {
30
+ "p1": calibration.x.p1,
31
+ "v1": calibration.x.v1,
32
+ "p2": calibration.x.p2,
33
+ "v2": calibration.x.v2,
34
+ "log": calibration.x.log,
35
+ },
36
+ "y": {
37
+ "p1": calibration.y.p1,
38
+ "v1": calibration.y.v1,
39
+ "p2": calibration.y.p2,
40
+ "v2": calibration.y.v2,
41
+ "log": calibration.y.log,
42
+ },
43
+ }
44
+
45
+
46
+ def calibration_from_dict(data):
47
+ """Build a Calibration from a dict, or None if it is malformed.
48
+
49
+ A corrupted or truncated file should not crash the app; returning
50
+ None means the overlay simply starts uncalibrated. The log flag is
51
+ optional (defaults to linear) so configs written before the log-axis
52
+ feature still load.
53
+ """
54
+ try:
55
+ x = AxisCalibration(
56
+ data["x"]["p1"],
57
+ data["x"]["v1"],
58
+ data["x"]["p2"],
59
+ data["x"]["v2"],
60
+ log=bool(data["x"].get("log", False)),
61
+ )
62
+ y = AxisCalibration(
63
+ data["y"]["p1"],
64
+ data["y"]["v1"],
65
+ data["y"]["p2"],
66
+ data["y"]["v2"],
67
+ log=bool(data["y"].get("log", False)),
68
+ )
69
+ except (KeyError, TypeError, ValueError):
70
+ return None
71
+ return Calibration(x, y)
72
+
73
+
74
+ def load(path):
75
+ """Read the config file; returns a dict, or {} if missing/corrupt."""
76
+ try:
77
+ with open(path, encoding="utf-8") as handle:
78
+ data = json.load(handle)
79
+ except (OSError, ValueError):
80
+ return {}
81
+ if not isinstance(data, dict):
82
+ return {}
83
+ return data
84
+
85
+
86
+ def save(path, geometry=None, calibration=None, hotkey=None, num_format=None):
87
+ """Write geometry, calibration, hotkey, and/or number format to config.
88
+
89
+ Existing values not being updated are preserved, so callers only need
90
+ to pass the fields they changed. Missing keys just keep their old
91
+ value; a fresh save with no prior file writes whatever is provided.
92
+ """
93
+ data = load(path)
94
+ if geometry is not None:
95
+ data["geometry"] = geometry
96
+ if calibration is not None:
97
+ data["calibration"] = calibration_to_dict(calibration)
98
+ if hotkey is not None:
99
+ data["hotkey"] = hotkey
100
+ if num_format is not None:
101
+ data["num_format"] = num_format
102
+ directory = os.path.dirname(path)
103
+ if directory:
104
+ os.makedirs(directory, exist_ok=True)
105
+ with open(path, "w", encoding="utf-8") as handle:
106
+ json.dump(data, handle, indent=2)
107
+
108
+
109
+ def calibration(path):
110
+ """Return the saved Calibration, or None."""
111
+ return calibration_from_dict(load(path).get("calibration"))
112
+
113
+
114
+ def hotkey(path):
115
+ """Return the saved hotkey config dict, or None."""
116
+ value = load(path).get("hotkey")
117
+ return value if isinstance(value, dict) else None
118
+
119
+
120
+ def num_format(path):
121
+ """Return the saved readout number format key, or None.
122
+
123
+ Falls back to None (the caller uses its default) if the value is
124
+ missing or not a known format, so a corrupted or older config does
125
+ not crash the app.
126
+ """
127
+ from .format import is_valid
128
+
129
+ value = load(path).get("num_format")
130
+ if isinstance(value, str) and is_valid(value):
131
+ return value
132
+ return None
133
+
134
+
135
+ def geometry(path):
136
+ """Return the saved geometry as a list, or None."""
137
+ value = load(path).get("geometry")
138
+ if (
139
+ isinstance(value, list)
140
+ and len(value) == 4
141
+ and all(isinstance(v, (int, float)) for v in value)
142
+ ):
143
+ return [int(v) for v in value]
144
+ return None
plotruler/titlebar.py ADDED
@@ -0,0 +1,226 @@
1
+ """The custom title bar of the overlay window.
2
+
3
+ There is no native title bar on a frameless translucent window, so we
4
+ paint our own: a translucent red strip with the app name and the
5
+ standard window controls (minimize to tray, maximize/restore, close).
6
+ Moving, snapping, and double-click-maximize are handled by the Win32
7
+ hit-test shim, which makes the OS treat this strip as a real caption,
8
+ so Aero Snap and external window tools work. Everything here is drawn
9
+ with QPainter — no native widgets.
10
+ """
11
+
12
+ from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt
13
+ from PySide6.QtGui import QColor, QFont, QPainter, QPen
14
+ from PySide6.QtWidgets import QWidget
15
+
16
+ from . import win_hittest
17
+
18
+ TITLEBAR_HEIGHT = 32
19
+ _BUTTON_WIDTH = 40
20
+ # The invisible resize hit-zone width, matching the overlay so the top edge
21
+ # and top corners resize the same way the other borders do on non-Windows.
22
+ _RESIZE_ZONE = 14
23
+ _GLYPH = QColor(220, 220, 220)
24
+ _HOVER_BG = QColor(255, 255, 255, 36)
25
+
26
+
27
+ class TitleBar(QWidget):
28
+ """A translucent title strip painted on the overlay."""
29
+
30
+ def __init__(self, parent=None, show_close=False):
31
+ super().__init__(parent)
32
+ self._min_rect = QRectF()
33
+ self._max_rect = QRectF()
34
+ self._close_rect = QRectF()
35
+ self._hover_button = None
36
+ self._pressed_button = None
37
+ # On platforms with no system tray there is no way to summon the
38
+ # overlay back after hiding, so hiding is a dead end; offer a real
39
+ # close (quit) button instead. On Windows the tray is the natural
40
+ # close path and no button is drawn.
41
+ self.show_close = show_close
42
+ self.setMouseTracking(True)
43
+
44
+ def _layout(self):
45
+ w = self.width()
46
+ # Buttons are laid right to left. With a close button present it
47
+ # takes the rightmost slot; otherwise minimize and maximize fill the
48
+ # two slots next to the edge, as on Windows.
49
+ right = w
50
+ if self.show_close:
51
+ self._close_rect = QRectF(
52
+ right - _BUTTON_WIDTH, 0, _BUTTON_WIDTH, self.height()
53
+ )
54
+ right -= _BUTTON_WIDTH
55
+ self._max_rect = QRectF(
56
+ right - _BUTTON_WIDTH, 0, _BUTTON_WIDTH, self.height()
57
+ )
58
+ self._min_rect = QRectF(
59
+ right - 2 * _BUTTON_WIDTH, 0, _BUTTON_WIDTH, self.height()
60
+ )
61
+
62
+ def is_over_buttons(self, pos):
63
+ """True when a window-coordinate point hits a control button."""
64
+ for rect in (self._min_rect, self._max_rect, self._close_rect):
65
+ if rect.contains(pos):
66
+ return True
67
+ return False
68
+
69
+ def _button_at(self, pos):
70
+ for rect, name in (
71
+ (self._min_rect, "min"),
72
+ (self._max_rect, "max"),
73
+ (self._close_rect, "close"),
74
+ ):
75
+ if rect.contains(pos):
76
+ return name
77
+ return None
78
+
79
+ def paintEvent(self, event):
80
+ painter = QPainter(self)
81
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
82
+
83
+ # Backing strip: translucent dark red so the bar reads clearly
84
+ # over any graph while the rest of the window stays see-through.
85
+ painter.fillRect(self.rect(), QColor(45, 10, 10, 220))
86
+ # A red accent line along the bottom so the bar's edge stays
87
+ # visible even over a black background.
88
+ painter.fillRect(
89
+ 0, self.height() - 2, self.width(), 2, QColor(255, 90, 90, 220)
90
+ )
91
+
92
+ # App name.
93
+ font = QFont()
94
+ font.setPointSize(9)
95
+ font.setBold(True)
96
+ painter.setFont(font)
97
+ painter.setPen(QColor(235, 235, 235))
98
+ # Reserve room for the buttons on the right; three when a close
99
+ # button is shown, two otherwise.
100
+ button_span = _BUTTON_WIDTH * (3 if self.show_close else 2)
101
+ painter.drawText(
102
+ QRectF(10, 0, self.width() - button_span - 20, self.height()),
103
+ Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
104
+ "PlotRuler",
105
+ )
106
+
107
+ self._draw_minimize(painter)
108
+ self._draw_maximize(painter)
109
+ if self.show_close:
110
+ self._draw_close(painter)
111
+
112
+ def _draw_minimize(self, painter):
113
+ if self._hover_button == "min":
114
+ painter.fillRect(self._min_rect, _HOVER_BG)
115
+ painter.setPen(QPen(_GLYPH, 2))
116
+ y = self._min_rect.center().y()
117
+ painter.drawLine(
118
+ QPointF(self._min_rect.x() + 14, y),
119
+ QPointF(self._min_rect.right() - 14, y),
120
+ )
121
+
122
+ def _draw_maximize(self, painter):
123
+ if self._hover_button == "max":
124
+ painter.fillRect(self._max_rect, _HOVER_BG)
125
+ painter.setPen(QPen(_GLYPH, 2))
126
+ rect = self._max_rect
127
+ if self.window().is_maximized():
128
+ # Restore icon: two overlapping squares.
129
+ painter.drawRect(QRectF(rect.x() + 9, rect.y() + 13, 14, 14))
130
+ painter.drawRect(QRectF(rect.x() + 13, rect.y() + 9, 14, 14))
131
+ else:
132
+ painter.drawRect(QRectF(rect.x() + 12, rect.y() + 9, 16, 16))
133
+
134
+ def _draw_close(self, painter):
135
+ """Draw an X glyph for the close button."""
136
+ if self._hover_button == "close":
137
+ painter.fillRect(self._close_rect, _HOVER_BG)
138
+ painter.setPen(QPen(_GLYPH, 2))
139
+ rect = self._close_rect
140
+ x = rect.center().x()
141
+ y = rect.center().y()
142
+ painter.drawLine(QPointF(x - 7, y - 7), QPointF(x + 7, y + 7))
143
+ painter.drawLine(QPointF(x - 7, y + 7), QPointF(x + 7, y - 7))
144
+
145
+ def mousePressEvent(self, event):
146
+ if event.button() != Qt.MouseButton.LeftButton:
147
+ return
148
+ if not win_hittest._IS_WINDOWS:
149
+ # On X11 there is no WM_NCHITTEST, so a press on an edge zone
150
+ # resizes and a press on the bar moves the window itself. Ask
151
+ # Qt to drive the gesture directly.
152
+ edges = self._resize_edges(event.position())
153
+ if edges is not None:
154
+ self.window().windowHandle().startSystemResize(edges)
155
+ event.accept()
156
+ return
157
+ if self._button_at(event.position()) is None:
158
+ self.window().windowHandle().startSystemMove()
159
+ event.accept()
160
+ return
161
+ # Remember which button was pressed, but do not act yet. Acting on
162
+ # press would let the button's release fall through to whatever
163
+ # window is underneath once we quit (see mouseReleaseEvent), which
164
+ # is how a click on PlotRuler's close button could also close the
165
+ # app beneath it.
166
+ self._pressed_button = self._button_at(event.position())
167
+ event.accept()
168
+
169
+ def _resize_edges(self, pos):
170
+ """Return the Qt.Edges for a top-edge resize at a titlebar point,
171
+ or None.
172
+
173
+ The titlebar covers the top strip, so the top border's resize zones
174
+ land here rather than in the overlay's mousePressEvent. Top corners
175
+ combine a vertical edge with a horizontal one; the middle of the bar
176
+ is a move, handled by the caller as a plain drag.
177
+ """
178
+ w = self.width()
179
+ x, y = pos.x(), pos.y()
180
+ if y >= _RESIZE_ZONE:
181
+ return None
182
+ edges = Qt.Edges()
183
+ if x < _RESIZE_ZONE:
184
+ edges |= Qt.Edge.LeftEdge
185
+ elif x >= w - _RESIZE_ZONE:
186
+ edges |= Qt.Edge.RightEdge
187
+ edges |= Qt.Edge.TopEdge
188
+ return edges
189
+
190
+ def mouseReleaseEvent(self, event):
191
+ if event.button() != Qt.MouseButton.LeftButton:
192
+ return
193
+ # Only act if the press began on the same button, so a click that
194
+ # starts elsewhere and ends here (or vice versa) does not fire.
195
+ button = self._button_at(event.position())
196
+ if button != self._pressed_button:
197
+ self._pressed_button = None
198
+ event.accept()
199
+ return
200
+ self._pressed_button = None
201
+ event.accept()
202
+ if button == "min":
203
+ self.window().minimize_to_tray()
204
+ elif button == "max":
205
+ self.window().toggle_maximize()
206
+ elif button == "close":
207
+ # On no-tray platforms there is no way back after hiding, so
208
+ # close really quits rather than depositing a ghost overlay.
209
+ self.window().quit()
210
+
211
+ def mouseMoveEvent(self, event):
212
+ hover = self._button_at(event.position())
213
+ if hover != self._hover_button:
214
+ self._hover_button = hover
215
+ self.update()
216
+
217
+ def leaveEvent(self, event):
218
+ self._hover_button = None
219
+ self.update()
220
+
221
+ def resizeEvent(self, event):
222
+ self._layout()
223
+ super().resizeEvent(event)
224
+
225
+ def sizeHint(self):
226
+ return QSizeF(0, TITLEBAR_HEIGHT).toSize()
plotruler/tray.py ADDED
@@ -0,0 +1,179 @@
1
+ """System tray icon for the resident overlay app.
2
+
3
+ The overlay is an always-on-top window, but it still needs a home in the
4
+ taskbar tray so it can be summoned and dismissed and quit without being
5
+ visible. This module builds the tray icon (drawn programmatically so no
6
+ image asset is needed) and wires a small context menu: show/hide, start a
7
+ new calibration, and quit.
8
+ """
9
+
10
+ from PySide6.QtCore import QObject, QPointF, QRect, QRectF, Qt
11
+ from PySide6.QtGui import (
12
+ QAction,
13
+ QActionGroup,
14
+ QColor,
15
+ QIcon,
16
+ QPainter,
17
+ QPixmap,
18
+ )
19
+ from PySide6.QtWidgets import QMenu, QSystemTrayIcon
20
+
21
+ from .format import NAMES, OPTIONS
22
+
23
+ # Accent colors matching the overlay so the tray icon feels consistent.
24
+ # The icon cyan is brighter than the overlay's so the crosshair arms have
25
+ # similar luminance (the amber Y-arm would otherwise outshine it).
26
+ _TRAY_RED_BG = QColor(90, 30, 30)
27
+ _ICON_CYAN = QColor(140, 230, 255)
28
+ _AMBER = QColor(255, 200, 80)
29
+ _LIGHT = QColor(235, 235, 235)
30
+
31
+
32
+ def make_icon(size=64):
33
+ """Build a PlotRuler tray icon.
34
+
35
+ A dark rounded tile with a graph-axes L (light) and a crosshair whose
36
+ two arms are the app's X/Y accent colors. The crosshair is the focal
37
+ point so the icon still reads as 'measure a point on a graph' at the
38
+ tiny size Windows tray icons render.
39
+
40
+ Everything is drawn on even pixel coordinates with integer rects so
41
+ the icon downsamples symmetrically: the crosshair sits at the tile's
42
+ center and each arm is exactly 4px thick, so at 16px the two arms
43
+ land on the same 1-pixel column/row and stay equally bright.
44
+
45
+ `size` is the render canvas in pixels. The layout is tuned for 64px
46
+ and scaled up proportionally for other sizes (e.g. a high-res icon
47
+ for the packaged executable).
48
+ """
49
+ k = size // 64
50
+ pixmap = QPixmap(size, size)
51
+ pixmap.fill(Qt.GlobalColor.transparent)
52
+
53
+ painter = QPainter(pixmap)
54
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
55
+
56
+ # Dark rounded tile so the light strokes pop on the taskbar tray.
57
+ painter.setPen(Qt.PenStyle.NoPen)
58
+ painter.setBrush(_TRAY_RED_BG)
59
+ painter.drawRoundedRect(QRectF(0, 0, size, size), 14 * k, 14 * k)
60
+
61
+ # Graph axes: a light L along the bottom and left. Rects are
62
+ # integer-aligned and 4px thick, symmetric about their center.
63
+ painter.setPen(Qt.PenStyle.NoPen)
64
+ painter.fillRect(QRect(8 * k, 52 * k, 48 * k, 4 * k), _LIGHT) # x-axis
65
+ painter.fillRect(QRect(8 * k, 8 * k, 4 * k, 48 * k), _LIGHT) # y-axis
66
+
67
+ # Crosshair arms: identical 4px-thick integer rects. The center is at
68
+ # 34 (not 32) because the 64->16 downsampler bins the canvas into 4x4
69
+ # blocks; an arm centered on 32 spans rows 30-33 and straddles two bins,
70
+ # lighting up two 16px rows. Centering on 34 (= 4*8 + 2) puts each arm
71
+ # wholly inside one bin, so both arms are single symmetric pixels.
72
+ thickness = 4 * k
73
+ half = 10 * k
74
+ t2 = thickness // 2
75
+ cx = cy = 34 * k
76
+ painter.fillRect(
77
+ QRect(cx - half, cy - t2, half * 2, thickness), _ICON_CYAN
78
+ ) # X arm (horizontal)
79
+ painter.fillRect(
80
+ QRect(cx - t2, cy - half, thickness, half * 2), _AMBER
81
+ ) # Y arm (vertical)
82
+
83
+ # A small light center dot so the crosshair reads as one target.
84
+ painter.setBrush(_LIGHT)
85
+ painter.drawEllipse(QPointF(cx, cy), 2.0 * k, 2.0 * k)
86
+
87
+ painter.end()
88
+ return QIcon(pixmap)
89
+
90
+
91
+ class TrayIcon(QObject):
92
+ """Owns the system tray icon and its menu, tied to the overlay window."""
93
+
94
+ def __init__(self, window, on_change_hotkey=None, parent=None):
95
+ super().__init__(parent)
96
+ self._window = window
97
+ self._on_change_hotkey = on_change_hotkey
98
+ self._tray = QSystemTrayIcon(make_icon(), self)
99
+ self._menu = QMenu()
100
+
101
+ self._toggle_action = QAction("Show / Hide", self)
102
+ self._toggle_action.triggered.connect(self._window.toggle_visibility)
103
+ self._new_action = QAction("New Calibration", self)
104
+ self._new_action.triggered.connect(self._start_calibration)
105
+ self._hotkey_action = QAction("Change Hotkey…", self)
106
+ self._hotkey_action.triggered.connect(self._change_hotkey)
107
+ self._quit_action = QAction("Quit", self)
108
+ self._quit_action.triggered.connect(self._quit)
109
+
110
+ self._format_menu = QMenu("Number Format", self._menu)
111
+ self._format_group = QActionGroup(self._format_menu)
112
+ self._format_actions = {}
113
+ for index, fmt in enumerate(OPTIONS):
114
+ # Number each option the same way the keyboard selects it, so
115
+ # the menu and the number-key shortcuts always agree.
116
+ action = QAction(f"{index + 1}. {NAMES[fmt]}", self._format_menu)
117
+ action.setCheckable(True)
118
+ action.triggered.connect(
119
+ lambda _checked, key=fmt: self._choose_format(key)
120
+ )
121
+ self._format_group.addAction(action)
122
+ self._format_actions[fmt] = action
123
+ self._format_menu.addAction(action)
124
+ self._format_menu.aboutToShow.connect(self._sync_format_check)
125
+
126
+ self._menu.addAction(self._toggle_action)
127
+ self._menu.addSeparator()
128
+ self._menu.addAction(self._new_action)
129
+ self._menu.addAction(self._hotkey_action)
130
+ self._menu.addSeparator()
131
+ self._menu.addMenu(self._format_menu)
132
+ self._menu.addSeparator()
133
+ self._menu.addAction(self._quit_action)
134
+
135
+ self._tray.setContextMenu(self._menu)
136
+ self._tray.setToolTip("PlotRuler")
137
+ self._tray.activated.connect(self._on_activated)
138
+ self._tray.show()
139
+
140
+ def _change_hotkey(self):
141
+ if self._on_change_hotkey is not None:
142
+ self._on_change_hotkey()
143
+
144
+ def _choose_format(self, fmt):
145
+ """Apply a number format chosen from the tray menu."""
146
+ self._window.set_num_format(fmt)
147
+
148
+ def _sync_format_check(self):
149
+ """Re-check the menu item matching the overlay's current format.
150
+
151
+ The format can change via the number-key shortcuts while the
152
+ overlay is focused, so the menu must reflect the live selection
153
+ each time it opens rather than remembering a stale check.
154
+ """
155
+ current = getattr(self._window, "_num_format", OPTIONS[0])
156
+ for fmt, action in self._format_actions.items():
157
+ action.setChecked(fmt == current)
158
+
159
+ def _start_calibration(self):
160
+ self._window.show()
161
+ self._window.raise_()
162
+ self._window.activateWindow()
163
+ self._window.start_calibration()
164
+
165
+ def _on_activated(self, reason):
166
+ # A single left click on the icon toggles the overlay, matching the
167
+ # hotkey and the "Show / Hide" menu item.
168
+ if reason in (
169
+ QSystemTrayIcon.ActivationReason.Trigger,
170
+ QSystemTrayIcon.ActivationReason.DoubleClick,
171
+ ):
172
+ self._window.toggle_visibility()
173
+
174
+ def _quit(self):
175
+ # QApplication.quit() stops the event loop; the tray icon and window
176
+ # are then torn down by the application object's destructor.
177
+ from PySide6.QtWidgets import QApplication
178
+
179
+ QApplication.quit()