diff --git a/Furious/Widget/MetricsGraph.py b/Furious/Widget/MetricsGraph.py index fb56271..0a614b2 100644 --- a/Furious/Widget/MetricsGraph.py +++ b/Furious/Widget/MetricsGraph.py @@ -25,10 +25,13 @@ from Furious.Qt import gettext as _ from Furious.Service.MetricsDataManager import MetricPoint from PySide6 import QtCore, QtGui -from PySide6.QtWidgets import QSizePolicy, QWidget +from PySide6.QtWidgets import QSizePolicy, QToolTip, QWidget from typing import Callable, Iterable +import bisect +import time + __all__ = ['MetricsGraphWidget'] @@ -45,6 +48,7 @@ class MetricsGraphWidget(QWidget): BottomMargin = 30 HorizontalGridLines = 4 VerticalGridLines = 4 + HoverDistance = 18 def __init__(self, direction: str, valueFormatter: Callable, parent=None): """Initialize an empty graph for one traffic direction.""" @@ -59,10 +63,15 @@ class MetricsGraphWidget(QWidget): self._direction = direction self._valueFormatter = valueFormatter self._points = tuple() + self._pointTimes = tuple() self._rangeSeconds = 300.0 self._currentTime = 0.0 + self._currentWallTime = 0.0 + self._metricLabel = '' + self._hoveredPoint = None self.setObjectName('MetricsGraphWidget') + self.setMouseTracking(True) self.setMinimumSize(340, 220) self.setSizePolicy( QSizePolicy.Policy.Expanding, @@ -74,21 +83,38 @@ class MetricsGraphWidget(QWidget): points: Iterable[MetricPoint], rangeSeconds: float, currentTime: float, + currentWallTime=None, ): """Replace the prepared series and schedule one repaint.""" self._points = tuple( point for point in points if isinstance(point, MetricPoint) ) + self._pointTimes = tuple(point.sampledAt for point in self._points) self._rangeSeconds = max(float(rangeSeconds), 1.0) self._currentTime = float(currentTime) + self._currentWallTime = ( + time.time() if currentWallTime is None else float(currentWallTime) + ) + + if self.underMouse(): + self._updateHover(self.mapFromGlobal(QtGui.QCursor.pos())) + else: + self._clearHover() + self.update() + def setMetricLabel(self, label: str): + """Set the translated metric name displayed in hover details.""" + self._metricLabel = str(label) + def clear(self): """Discard displayed points and schedule one repaint.""" if not self._points: return self._points = tuple() + self._pointTimes = tuple() + self._clearHover() self.update() def _palette(self): @@ -209,21 +235,26 @@ class MetricsGraphWidget(QWidget): _('Waiting for network statistics'), ) + def _pointPosition(self, point, chartRect, maximumValue): + """Map one metric point into plot coordinates.""" + startTime = self._currentTime - self._rangeSeconds + xRatio = min( + max((point.sampledAt - startTime) / self._rangeSeconds, 0.0), + 1.0, + ) + yRatio = min(max(point.value / maximumValue, 0.0), 1.0) + + return QtCore.QPointF( + chartRect.left() + (xRatio * chartRect.width()), + chartRect.bottom() - (yRatio * chartRect.height()), + ) + def _graphPath(self, chartRect, maximumValue): """Convert prepared metric points into a painter path.""" - startTime = self._currentTime - self._rangeSeconds path = QtGui.QPainterPath() for index, point in enumerate(self._points): - xRatio = min( - max((point.sampledAt - startTime) / self._rangeSeconds, 0.0), - 1.0, - ) - yRatio = min(max(point.value / maximumValue, 0.0), 1.0) - pointPosition = QtCore.QPointF( - chartRect.left() + (xRatio * chartRect.width()), - chartRect.bottom() - (yRatio * chartRect.height()), - ) + pointPosition = self._pointPosition(point, chartRect, maximumValue) if index == 0: path.moveTo(pointPosition) @@ -232,6 +263,154 @@ class MetricsGraphWidget(QWidget): return path + def _maximumValue(self): + """Return the padded vertical-axis maximum for current points.""" + maximumValue = max( + (point.value for point in self._points), + default=0.0, + ) + + return max(maximumValue * 1.1, 1.0) + + def _nearestPoint(self, position): + """Return the point nearest the cursor's horizontal time position.""" + if not self._points: + return None + + chartRect = self._chartRect() + + if not chartRect.contains(QtCore.QPointF(position)): + return None + + ratio = (position.x() - chartRect.left()) / chartRect.width() + targetTime = ( + self._currentTime - self._rangeSeconds + ) + ratio * self._rangeSeconds + insertionIndex = bisect.bisect_left(self._pointTimes, targetTime) + candidates = [] + + if insertionIndex < len(self._points): + candidates.append(self._points[insertionIndex]) + + if insertionIndex > 0: + candidates.append(self._points[insertionIndex - 1]) + + nearest = min( + candidates, + key=lambda point: abs(point.sampledAt - targetTime), + default=None, + ) + + if nearest is None: + return None + + pointPosition = self._pointPosition( + nearest, + chartRect, + self._maximumValue(), + ) + + if abs(pointPosition.x() - position.x()) > self.HoverDistance: + return None + + return nearest + + def _tooltipText(self, point) -> str: + """Return timestamp and formatted metric details for one point.""" + recordedAt = self._currentWallTime - (self._currentTime - point.sampledAt) + timestamp = QtCore.QDateTime.fromMSecsSinceEpoch( + int(recordedAt * 1000) + ).toString('yyyy-MM-dd HH:mm:ss') + value = self._valueFormatter(point.value) + + if self._metricLabel: + value = f'{self._metricLabel}: {value}' + + return f'{timestamp}\n{value}' + + def _clearHover(self): + """Clear hover state and dismiss the native Qt tooltip.""" + changed = self._hoveredPoint is not None + + self._hoveredPoint = None + + if changed: + QToolTip.hideText() + + self.update() + + def _updateHover(self, position): + """Select the nearest graph point and show its native tooltip.""" + point = self._nearestPoint(position) + + if point is None: + self._clearHover() + + return + + changed = point is not self._hoveredPoint + + self._hoveredPoint = point + + tooltipPosition = self.mapToGlobal(position) + QtCore.QPoint(14, 18) + + QToolTip.showText( + tooltipPosition, + self._tooltipText(point), + self, + ) + + if changed: + self.update() + + def _drawHover(self, painter, chartRect, maximumValue, palette): + """Draw a crosshair and highlighted marker for the hovered point.""" + if self._hoveredPoint is None: + return + + pointPosition = self._pointPosition( + self._hoveredPoint, + chartRect, + maximumValue, + ) + + crosshairColor = QtGui.QColor(palette['metrics_axis']) + crosshairColor.setAlpha(150) + + crosshairPen = QtGui.QPen(crosshairColor) + crosshairPen.setStyle(QtCore.Qt.PenStyle.DashLine) + + painter.setPen(crosshairPen) + painter.drawLine( + QtCore.QPointF(pointPosition.x(), chartRect.top()), + QtCore.QPointF(pointPosition.x(), chartRect.bottom()), + ) + painter.setBrush(QtGui.QColor(palette['panel_alt'])) + + markerPen = QtGui.QPen(self._lineColor(palette)) + markerPen.setWidthF(2.5) + + painter.setPen(markerPen) + painter.drawEllipse(pointPosition, 5.0, 5.0) + + def mouseMoveEvent(self, event): + """Inspect the graph point nearest the moving cursor.""" + self._updateHover(event.position().toPoint()) + + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + """Dismiss hover details when the cursor leaves the graph.""" + self._clearHover() + + super().leaveEvent(event) + + def hideEvent(self, event): + """Dismiss hover details when lazy page rendering becomes hidden.""" + self._clearHover() + + super().hideEvent(event) + def paintEvent(self, event): """Paint axes, an optional area fill, and the current metric line.""" super().paintEvent(event) @@ -241,11 +420,7 @@ class MetricsGraphWidget(QWidget): palette = self._palette() chartRect = self._chartRect() - maximumValue = max( - (point.value for point in self._points), - default=0.0, - ) - maximumValue = max(maximumValue * 1.1, 1.0) + maximumValue = self._maximumValue() self._drawGrid(painter, chartRect, palette, maximumValue) @@ -287,4 +462,7 @@ class MetricsGraphWidget(QWidget): painter.setBrush(self._lineColor(palette)) painter.setPen(QtCore.Qt.PenStyle.NoPen) painter.drawEllipse(path.currentPosition(), 3.5, 3.5) + + self._drawHover(painter, chartRect, maximumValue, palette) + painter.end() diff --git a/Furious/Window/MetricsPage.py b/Furious/Window/MetricsPage.py index e46f03c..b3d89ce 100644 --- a/Furious/Window/MetricsPage.py +++ b/Furious/Window/MetricsPage.py @@ -63,6 +63,7 @@ class _MetricCard(QFrame): def setTitle(self, title: str): """Set the already translated metric title.""" self.titleLabel.setText(title) + self.graph.setMetricLabel(title) class _MetricsSection(QFrame): @@ -265,6 +266,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): self.granularityComboBox.currentData() or self.DefaultGranularity ) currentTime = time.monotonic() + currentWallTime = time.time() graphMetrics = ( (self.downloadSpeedGraph, DOWNLOAD_SPEED_METRIC), (self.downloadUsageGraph, DOWNLOAD_USAGE_METRIC), @@ -282,6 +284,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow): ), rangeSeconds, currentTime, + currentWallTime, ) self._dirty = False