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