diff --git a/Furious/Qt/AppStyleSheet.py b/Furious/Qt/AppStyleSheet.py index 4568a15..a6e3d8c 100644 --- a/Furious/Qt/AppStyleSheet.py +++ b/Furious/Qt/AppStyleSheet.py @@ -967,7 +967,7 @@ class AppStyleSheet: color: {palette['danger']}; }} - QWidget#TrafficSpeedBadge {{ + QWidget#TrafficStatsBadge {{ min-height: 24px; padding: 0 2px; border: 1px solid {palette['border']}; @@ -976,13 +976,17 @@ class AppStyleSheet: color: {palette['text_strong']}; }} - QWidget#TrafficSpeedBadge QLabel {{ + QWidget#TrafficStatsBadge QLabel {{ border: none; padding: 0; background-color: transparent; color: {palette['text_strong']}; }} + QWidget#TrafficStatsBadge QLabel#TrafficUsageLabel {{ + color: {palette['muted']}; + }} + QSplitter::handle {{ background-color: {palette['border']}; }} diff --git a/Furious/Service/TrafficStatsManager.py b/Furious/Service/TrafficStatsManager.py index d08002f..bc608a4 100644 --- a/Furious/Service/TrafficStatsManager.py +++ b/Furious/Service/TrafficStatsManager.py @@ -29,7 +29,11 @@ import multiprocessing import queue import time -__all__ = ['TrafficStatsManager', 'formatTrafficSpeed'] +__all__ = [ + 'TrafficStatsManager', + 'formatTrafficSpeed', + 'formatTrafficUsage', +] logger = logging.getLogger(__name__) @@ -61,6 +65,30 @@ def formatTrafficSpeed(bytesPerSecond: float) -> str: return f'{formatted} {unit}' +def formatTrafficUsage(bytesUsed: int) -> str: + """Format cumulative traffic using compact binary units.""" + try: + value = max(int(bytesUsed), 0) + except (TypeError, ValueError, OverflowError): + value = 0 + + if value < KIBIBYTE: + return f'{value} B' + + amount = float(value) + units = ('KiB', 'MiB', 'GiB', 'TiB', 'PiB') + + for unit in units: + amount /= KIBIBYTE + + if amount < KIBIBYTE or unit == units[-1]: + formatted = f'{amount:.2f}'.rstrip('0').rstrip('.') + + return f'{formatted} {unit}' + + return f'{value} B' + + def _queryTrafficStats(requestQueue, resultQueue): """Run potentially GIL-blocking statistics calls in an isolated process.""" while True: @@ -87,9 +115,10 @@ class TrafficStatsManager( Mixins.CleanupOnExit, QtCore.QObject, ): - """Periodically convert cumulative plugin counters into traffic speeds.""" + """Publish plugin traffic usage and rates while its parent is visible.""" speedChanged = QtCore.Signal(float, float) + usageChanged = QtCore.Signal(object, object) statisticsUnavailable = QtCore.Signal() def __init__(self, parent=None): @@ -114,6 +143,71 @@ class TrafficStatsManager( self._resultTimer.setInterval(TRAFFIC_STATS_RESULT_INTERVAL) self._resultTimer.timeout.connect(self._consumeResults) + if parent is not None: + parent.installEventFilter(self) + + def _parentIsVisible(self) -> bool: + """Return whether the parent widget can currently display updates.""" + parent = self.parent() + + if parent is None or not hasattr(parent, 'isVisible'): + return True + + return parent.isVisible() and not ( + hasattr(parent, 'isMinimized') and parent.isMinimized() + ) + + def _suspendSampling(self): + """Pause polling and discard the current rate baseline.""" + wasActive = ( + self._sampleTimer.isActive() + or self._resultTimer.isActive() + or self._queryInFlight + ) + self._sampleTimer.stop() + self._resultTimer.stop() + + if wasActive: + self._generation += 1 + self._resetSamples() + + def _resumeSampling(self): + """Resume polling when a monitor and visible parent are available.""" + if self._monitor is None or not self._parentIsVisible(): + return + + if not self._ensureWorker(): + self.statisticsUnavailable.emit() + + return + + self._resultTimer.start() + self._sampleTimer.start() + self._requestSample() + + @QtCore.Slot() + def _syncSamplingVisibility(self): + """Synchronize polling with the parent widget's visibility.""" + if self._parentIsVisible(): + self._resumeSampling() + else: + self._suspendSampling() + + def eventFilter(self, watched, event): + """Pause traffic queries while the parent widget is not visible.""" + if watched is self.parent(): + eventType = event.type() + + if eventType == QtCore.QEvent.Type.Hide: + self._suspendSampling() + elif eventType in ( + QtCore.QEvent.Type.Show, + QtCore.QEvent.Type.WindowStateChange, + ): + QtCore.QTimer.singleShot(0, self._syncSamplingVisibility) + + return super().eventFilter(watched, event) + @staticmethod def _activeProcesses(): """Return the processes owned by the active tray connection.""" @@ -184,6 +278,8 @@ class TrafficStatsManager( def _activateMonitor(self, monitor): """Begin sampling one plugin-provided monitor.""" + self._sampleTimer.stop() + self._resultTimer.stop() self._generation += 1 self._monitor = monitor self._resetSamples() @@ -194,19 +290,12 @@ class TrafficStatsManager( return - if not self._ensureWorker(): - self.statisticsUnavailable.emit() - - return - - self._resultTimer.start() - self._sampleTimer.start() - self._requestSample() + self._resumeSampling() @QtCore.Slot() def _requestSample(self): """Queue one statistics query unless another query is still running.""" - if self._monitor is None or self._queryInFlight: + if self._monitor is None or self._queryInFlight or not self._parentIsVisible(): return if not self._ensureWorker(): @@ -227,6 +316,8 @@ class TrafficStatsManager( def _updateSpeeds(self, counters: TrafficCounters, sampledAt: float): """Calculate and emit rates from one cumulative counter sample.""" + self.usageChanged.emit(counters.uplink, counters.downlink) + previousCounters = self._previousCounters previousSampleTime = self._previousSampleTime self._previousCounters = counters @@ -252,6 +343,11 @@ class TrafficStatsManager( @QtCore.Slot() def _consumeResults(self): """Drain worker results and ignore samples from older connections.""" + if not self._parentIsVisible(): + self._suspendSampling() + + return + resultQueue = self._resultQueue if resultQueue is None: diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index c4eb664..c5737ef 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -27,7 +27,11 @@ from .SubscriptionImporter import ( SubscriptionImportService, SubscriptionSource, ) -from .TrafficStatsManager import TrafficStatsManager, formatTrafficSpeed +from .TrafficStatsManager import ( + TrafficStatsManager, + formatTrafficSpeed, + formatTrafficUsage, +) from .UpdateManager import UpdateManager __all__ = [ @@ -40,4 +44,5 @@ __all__ = [ 'TrafficStatsManager', 'UpdateManager', 'formatTrafficSpeed', + 'formatTrafficUsage', ] diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 027b658..85c5aef 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -31,6 +31,7 @@ from Furious.Service import ( TrafficStatsManager, UpdateManager, formatTrafficSpeed, + formatTrafficUsage, ) from Furious.Actions.Import import ( ImportFromFileAction, @@ -258,33 +259,40 @@ class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget): self.updateStatusText() -class TrafficSpeedBadge(Mixins.ThemeAware, QWidget): - """Display independently updated upload and download speeds.""" +class TrafficStatsBadge(Mixins.ThemeAware, QWidget): + """Display independently updated traffic speeds and usage.""" - UploadIconFileName = 'cloud-arrow-up.svg' - DownloadIconFileName = 'cloud-arrow-down.svg' + UploadIconFileName = 'cloud-upload.svg' + DownloadIconFileName = 'cloud-download.svg' IconSize = QtCore.QSize(16, 16) def __init__(self, parent=None): """Initialize dedicated traffic-direction icons and labels.""" super().__init__(parent) - self.setObjectName('TrafficSpeedBadge') + self.setObjectName('TrafficStatsBadge') self.setVisible(False) self.uploadIconLabel = QLabel(parent=self) self.uploadTextLabel = AppQLabel(translatable=False, parent=self) + self.uploadUsageLabel = AppQLabel(translatable=False, parent=self) self.downloadIconLabel = QLabel(parent=self) self.downloadTextLabel = AppQLabel(translatable=False, parent=self) + self.downloadUsageLabel = AppQLabel(translatable=False, parent=self) + + self.uploadUsageLabel.setObjectName('TrafficUsageLabel') + self.downloadUsageLabel.setObjectName('TrafficUsageLabel') self._layout = QHBoxLayout(self) self._layout.setContentsMargins(8, 3, 8, 3) self._layout.setSpacing(6) self._layout.addWidget(self.uploadIconLabel) self._layout.addWidget(self.uploadTextLabel) + self._layout.addWidget(self.uploadUsageLabel) self._layout.addSpacing(4) self._layout.addWidget(self.downloadIconLabel) self._layout.addWidget(self.downloadTextLabel) + self._layout.addWidget(self.downloadUsageLabel) self.setIconByTheme(APP().theme()) @@ -307,11 +315,20 @@ class TrafficSpeedBadge(Mixins.ThemeAware, QWidget): self.downloadTextLabel.setText(formatTrafficSpeed(download)) self.setVisible(True) + @QtCore.Slot(object, object) + def setUsage(self, upload: int, download: int): + """Display formatted cumulative upload and download usage.""" + self.uploadUsageLabel.setText(f'({formatTrafficUsage(upload)})') + self.downloadUsageLabel.setText(f'({formatTrafficUsage(download)})') + self.setVisible(True) + @QtCore.Slot() - def clearSpeeds(self): - """Hide stale speeds when statistics are unavailable.""" + def clearStatistics(self): + """Hide stale speed and usage values when statistics are unavailable.""" self.uploadTextLabel.clear() + self.uploadUsageLabel.clear() self.downloadTextLabel.clear() + self.downloadUsageLabel.clear() self.setVisible(False) def themeChangedCallback(self, theme: str): @@ -328,13 +345,13 @@ class ConnectionStatusWidget(QWidget): self.setObjectName('ConnectionStatusWidget') self.networkState = NetworkStateBadge(parent=self) - self.trafficSpeed = TrafficSpeedBadge(parent=self) + self.trafficStats = TrafficStatsBadge(parent=self) self._layout = QHBoxLayout(self) self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(6) + self._layout.addWidget(self.trafficStats) self._layout.addWidget(self.networkState) - self._layout.addWidget(self.trafficSpeed) class SearchButton(AppQPushButton): @@ -812,11 +829,12 @@ class MainWindow(AppQMainWindow): self.connectionStatus = ConnectionStatusWidget(parent=self) self.networkState = self.connectionStatus.networkState - self.trafficSpeed = self.connectionStatus.trafficSpeed + self.trafficStats = self.connectionStatus.trafficStats self.trafficStatsManager = TrafficStatsManager(parent=self) - self.trafficStatsManager.speedChanged.connect(self.trafficSpeed.setSpeeds) + self.trafficStatsManager.speedChanged.connect(self.trafficStats.setSpeeds) + self.trafficStatsManager.usageChanged.connect(self.trafficStats.setUsage) self.trafficStatsManager.statisticsUnavailable.connect( - self.trafficSpeed.clearSpeeds + self.trafficStats.clearStatistics ) self.statusBar().addPermanentWidget(self.connectionStatus)