Improve traffic status presentation

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:49 +08:00
parent 6a1ff775bd
commit 400fe19400
4 changed files with 149 additions and 26 deletions
+6 -2
View File
@@ -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']};
}}
+107 -11
View File
@@ -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:
+6 -1
View File
@@ -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',
]
+30 -12
View File
@@ -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)