-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathping_widget.py
More file actions
316 lines (262 loc) · 11.4 KB
/
Copy pathping_widget.py
File metadata and controls
316 lines (262 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
import sys
import socket
import time
import numpy as np
import json
import os
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton, QMenu, QToolTip, QSystemTrayIcon
from PySide6.QtCore import Qt, QTimer, QPoint, QRect
from PySide6.QtGui import QCloseEvent, QAction, QCursor, QIcon, QPixmap, QPainter, QColor
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
VERSION = "3.3"
# --- Main Settings ---
UPDATE_INTERVAL_MS = 1000
MAX_DATA_POINTS = 100
SETTINGS_FILE = "ping_widget_settings.json"
CONFIG_FILE = "ping_widget_config.json"
# --- Default Config ---
DEFAULT_CONFIG = {
"ping_target": "8.8.8.8",
"ping_port": 53,
"ping_timeout": 1.0
}
class SmartHandle(QPushButton):
"""A smart handle button for moving and resizing the main window."""
def __init__(self, parent=None):
super().__init__(parent)
button_size = 4
self.setFixedSize(button_size, button_size)
self.setToolTip(
"🖱 Drag → Move window\n"
"Ctrl + Drag → Resize window\n"
"Right-click anywhere → Exit"
)
self.setStyleSheet(f"""
QPushButton {{
background-color: rgba(255, 0, 0, 180); border: none; border-radius: {button_size // 1}px;
}}
QPushButton:hover {{ background-color: rgba(255, 0, 0, 220); }}
""")
# State variables for dragging and resizing
self.current_action = None
self.drag_start_position = QPoint()
self.window_start_geometry = QRect()
def mousePressEvent(self, event):
"""Initiates move or resize action based on Ctrl key state."""
if event.button() == Qt.MouseButton.LeftButton:
self.drag_start_position = event.globalPosition().toPoint()
self.window_start_geometry = self.parent().geometry()
modifiers = QApplication.keyboardModifiers()
if modifiers == Qt.KeyboardModifier.ControlModifier:
self.current_action = 'resize'
self.setCursor(Qt.CursorShape.SizeFDiagCursor)
else:
self.current_action = 'move'
self.setCursor(Qt.CursorShape.SizeAllCursor)
event.accept()
def mouseMoveEvent(self, event):
"""Handles the window movement or resizing."""
if not self.current_action or not event.buttons() == Qt.MouseButton.LeftButton:
return
delta = event.globalPosition().toPoint() - self.drag_start_position
if self.current_action == 'move':
new_pos = self.window_start_geometry.topLeft() + delta
self.parent().move(new_pos)
elif self.current_action == 'resize':
start_size = self.window_start_geometry.size()
new_width = start_size.width() + delta.x()
new_height = start_size.height() + delta.y()
min_w, min_h = 100, 20
if new_width < min_w: new_width = min_w
if new_height < min_h: new_height = min_h
self.parent().resize(new_width, new_height)
event.accept()
def enterEvent(self, event):
QToolTip.showText(
QCursor.pos(),
"Drag → Move\nCtrl + Drag → Resize\nRight-click → Exit",
self
)
def mouseReleaseEvent(self, event):
"""Finalizes the action and saves the new settings."""
if self.current_action:
self.parent().save_settings()
self.current_action = None
self.setCursor(Qt.CursorShape.ArrowCursor)
event.accept()
class MatplotlibCanvas(FigureCanvas):
"""A custom widget to display the Matplotlib chart."""
def __init__(self, parent=None, dpi=100):
self.fig = Figure(dpi=dpi, facecolor='none')
self.axes = self.fig.add_subplot(111)
super().__init__(self.fig)
# Make the chart background transparent
self.fig.patch.set_alpha(0.0)
self.axes.patch.set_alpha(0.0)
# Configure the chart appearance
self.axes.axis('off')
self.axes.set_xlim(0, MAX_DATA_POINTS)
self.axes.set_ylim(0, 50) # Initial Y-axis limit
self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
class PingWidget(QMainWindow):
"""The main widget window."""
def __init__(self):
super().__init__()
# Set window flags for a frameless, always-on-top window that never loses focus
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool |
Qt.WindowType.WindowDoesNotAcceptFocus
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True)
self.setStyleSheet("background:transparent;")
# Load ping config
self.config = self.load_config()
# Load previous geometry or set a default
self.load_settings()
# System tray icon
self._setup_tray()
# Set up the main chart canvas
self.canvas = MatplotlibCanvas(self)
self.setCentralWidget(self.canvas)
# Create the smart handle as a floating child widget
self.smart_handle = SmartHandle(self)
# Initialize chart data
self.x_data = np.arange(MAX_DATA_POINTS)
self.y_data = np.zeros(MAX_DATA_POINTS)
self.max_ping_so_far = 50
# Create plot lines
self.line, = self.canvas.axes.plot(self.x_data, self.y_data, color='#00aaff', lw=1)
self.error_points, = self.canvas.axes.plot([], [], 'o', color='red', markersize=5)
# Timer for updating the plot
self.plot_timer = QTimer(self)
self.plot_timer.setInterval(UPDATE_INTERVAL_MS)
self.plot_timer.timeout.connect(self.update_plot)
self.plot_timer.start()
# Enable a context menu for exiting
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_exit_menu)
def _setup_tray(self):
"""Creates a system tray icon with show/hide and exit options."""
# Draw a simple blue circle as the tray icon
px = QPixmap(16, 16)
px.fill(Qt.GlobalColor.transparent)
painter = QPainter(px)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor("#00aaff"))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(2, 2, 12, 12)
painter.end()
self.tray = QSystemTrayIcon(QIcon(px), self)
self.tray.setToolTip("Ping Widget")
menu = QMenu()
show_action = menu.addAction("Show / Hide")
show_action.triggered.connect(self._toggle_visibility)
menu.addSeparator()
exit_action = menu.addAction("Exit")
exit_action.triggered.connect(self.close)
self.tray.setContextMenu(menu)
self.tray.activated.connect(self._tray_activated)
self.tray.show()
def _toggle_visibility(self):
if self.isVisible():
self.hide()
else:
self.show()
self.raise_()
def _tray_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
self._toggle_visibility()
def load_config(self):
"""Loads ping config from file, falls back to defaults."""
config = DEFAULT_CONFIG.copy()
try:
base_path = os.path.dirname(os.path.abspath(sys.argv[0]))
config_path = os.path.join(base_path, CONFIG_FILE)
with open(config_path, 'r') as f:
loaded = json.load(f)
config.update({k: v for k, v in loaded.items() if k in DEFAULT_CONFIG})
except (FileNotFoundError, json.JSONDecodeError):
pass
return config
def get_ping_time(self, host, port, timeout):
"""Measures TCP latency to host:port. No subprocess — no DLL conflicts."""
try:
start = time.perf_counter()
sock = socket.create_connection((host, port), timeout=timeout)
sock.close()
return int((time.perf_counter() - start) * 1000)
except Exception:
return None
def update_plot(self):
"""Fetches new ping data and updates the chart."""
ping_time = self.get_ping_time(
self.config["ping_target"],
self.config["ping_port"],
self.config["ping_timeout"]
)
# Roll the data array to the left
self.y_data = np.roll(self.y_data, -1)
self.y_data[-1] = ping_time if ping_time is not None else 0
# Update the dynamic Y-axis limit
if ping_time and ping_time > self.max_ping_so_far:
self.max_ping_so_far = ping_time
self.canvas.axes.set_ylim(-self.max_ping_so_far * 0.05, self.max_ping_so_far * 1.1)
# Update plot data
self.line.set_ydata(self.y_data)
error_indices = np.where(self.y_data == 0)[0]
self.error_points.set_data(self.x_data[error_indices], self.y_data[error_indices])
# Redraw the canvas
self.canvas.draw()
def resizeEvent(self, event):
"""Moves the smart handle to the bottom-right corner when the window is resized."""
super().resizeEvent(event)
btn_x = self.width() - self.smart_handle.width() - 5
btn_y = self.height() - self.smart_handle.height() - 5
self.smart_handle.move(btn_x, btn_y)
def get_settings_path(self):
"""Returns the full path for the settings file."""
base_path = os.path.dirname(os.path.abspath(sys.argv[0]))
return os.path.join(base_path, SETTINGS_FILE)
def load_settings(self):
"""Loads window geometry from the settings file."""
screen = QApplication.primaryScreen().availableGeometry()
default_w, default_h = 400, 100
default_x = screen.right() - default_w - int(screen.width() * 0.05)
default_y = screen.top() + int(screen.height() * 0.05)
try:
with open(self.get_settings_path(), 'r') as f:
settings = json.load(f)
x = settings.get('x', default_x)
y = settings.get('y', default_y)
w = settings.get('width', default_w)
h = settings.get('height', default_h)
# If saved position is off-screen, reset to default
if not screen.contains(QPoint(x + w // 2, y + h // 2)):
x, y = default_x, default_y
self.setGeometry(x, y, w, h)
except (FileNotFoundError, json.JSONDecodeError):
self.setGeometry(default_x, default_y, default_w, default_h)
def save_settings(self):
"""Saves the current window geometry to the settings file."""
settings = {'x': self.x(), 'y': self.y(), 'width': self.width(), 'height': self.height()}
with open(self.get_settings_path(), 'w') as f:
json.dump(settings, f, indent=4)
def show_exit_menu(self, pos):
"""Shows a context menu with an 'Exit' option."""
menu = QMenu()
menu.addAction("Exit", self.close)
menu.exec(self.mapToGlobal(pos))
def closeEvent(self, event: QCloseEvent):
"""Ensures settings are saved before the application quits."""
self.save_settings()
QApplication.quit()
super().closeEvent(event)
if __name__ == '__main__':
app = QApplication(sys.argv)
widget = PingWidget()
widget.show()
sys.exit(app.exec())