Files
LorenEteval_Furious/Furious/Widget/EndpointInfoWidget.py
T
2026-08-24 19:32:03 +08:00

958 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Present connection-scoped proxy endpoint information on the metrics page."""
from __future__ import annotations
from Furious.Frozenlib import APP, DATA_DIR, PLATFORM, Mixins
from Furious.Qt import AppQLabel, AppQPushButton, AppStyleSheet, bootstrapIcon
from Furious.Qt import gettext as _
from Furious.Service import EndpointInfo, EndpointInfoState
from Furious.Widget.WaitingSpinner import WaitingSpinner
from PySide6 import QtCore, QtGui
from PySide6.QtWidgets import *
import json
import logging
__all__ = ['EndpointInfoWidget']
logger = logging.getLogger(__name__)
try:
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
except ImportError:
(
QWebChannel,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
QWebEngineView,
) = (
None,
None,
None,
None,
None,
)
_EndpointWebViewBase = QWebEngineView if QWebEngineView is not None else QWidget
class _EndpointWebView(_EndpointWebViewBase):
"""Keep map wheel gestures from bubbling into the Metrics scroll area."""
def wheelEvent(self, event):
"""Let the map process the gesture, then consume it at this boundary."""
super().wheelEvent(event)
event.accept()
class _EndpointMapBridge(QtCore.QObject):
"""Expose the narrow, trusted callback surface used by the local map page."""
mapReady, mapFailed, externalLinkRequested = (
QtCore.Signal(),
QtCore.Signal(str),
QtCore.Signal(str),
)
@QtCore.Slot()
def ready(self):
"""Publish that the initial vector style is ready for presentation."""
self.mapReady.emit()
@QtCore.Slot(str)
def failed(self, message):
"""Publish an error reported by the embedded map renderer."""
self.mapFailed.emit(str(message or ''))
@QtCore.Slot(str)
def openExternal(self, link):
"""Request external navigation without allowing in-view navigation."""
self.externalLinkRequested.emit(str(link or ''))
class _EndpointMapWidget(QWidget):
"""Host one lazily loaded MapLibre vector map for the page lifetime."""
HtmlPath = DATA_DIR / 'maplibre' / 'EndpointMap.html'
TrustedAttributionHosts = frozenset(
{
'openfreemap.org',
'www.openfreemap.org',
'openmaptiles.org',
'www.openmaptiles.org',
'openstreetmap.org',
'www.openstreetmap.org',
}
)
DefaultGeographicZoom = 6.0
UserAgent = 'Mozilla/5.0'
def __init__(self, parent=None):
"""Initialize an inert map whose lifetime follows its containing card."""
super().__init__(parent)
self._location = None
self._message = ''
self._unavailableText = ''
self._loadingText = ''
self._loading = False
self._active = False
self._sourceLoaded = False
self._documentLoaded = False
self._mapReady = False
self._mapError = ''
self._rendererFailureHandled = False
self._viewRevision = 0
self._lastWebState = {}
self._retainMapDuringRefresh = False
self._theme = self._resolvedTheme()
self.setObjectName('EndpointMapWidget')
self.setMinimumHeight(260)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.statusOverlay = QWidget(self)
self.statusOverlay.setAutoFillBackground(True)
self.statusLabel = AppQLabel(
translatable=False,
parent=self.statusOverlay,
)
self.statusLabel.setObjectName('EndpointMapPlaceholder')
self.statusLabel.setWordWrap(False)
self.loadingSpinner = WaitingSpinner(
self.statusOverlay,
center_on_parent=False,
line_length=5,
line_width=2,
radius=4,
lines=12,
)
self.loadingSpinner.setFixedSize(22, 22)
statusLayout = QHBoxLayout(self.statusOverlay)
statusLayout.setContentsMargins(12, 12, 12, 12)
statusLayout.setSpacing(8)
statusLayout.addStretch(1)
statusLayout.addWidget(self.loadingSpinner)
statusLayout.addWidget(self.statusLabel)
statusLayout.addStretch(1)
# The off-the-record profile, page, channel, bridge, and WebEngine view
# are all persistent children of this page-lifetime map widget. This
# keeps vector-tile caching in memory and gives Chromium one bounded,
# explicit teardown path with the containing Metrics page.
#
# QWebEngineView owns its automatically created page but does not own
# the custom profile. Parent the profile to the view only after that
# page exists so QObject child destruction releases the page first.
self.webProfile = QWebEngineProfile()
self.webProfile.setHttpCacheType(
QWebEngineProfile.HttpCacheType.MemoryHttpCache
)
self.webProfile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
)
self.webProfile.setHttpUserAgent(self.UserAgent)
self.webView = _EndpointWebView(self.webProfile, self)
self.webProfile.setParent(self.webView)
self.webView.setObjectName('EndpointLocationMap')
self.webView.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
self.webView.page().setBackgroundColor(QtCore.Qt.GlobalColor.transparent)
webSettings = self.webView.settings()
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
True,
)
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows,
False,
)
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.WebGLEnabled,
True,
)
self.webChannel = QWebChannel(self.webView.page())
self.webBridge = _EndpointMapBridge(self.webChannel)
self.webChannel.registerObject('endpointMapBridge', self.webBridge)
self.webView.page().setWebChannel(self.webChannel)
self.webBridge.mapReady.connect(self._mapBecameReady)
self.webBridge.mapFailed.connect(self._mapFailed)
self.webBridge.externalLinkRequested.connect(self._openExternalLink)
self.webView.loadFinished.connect(self._loadFinished)
self.webView.page().renderProcessTerminated.connect(
self._renderProcessTerminated
)
layout = QGridLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.webView, 0, 0)
layout.addWidget(self.statusOverlay, 0, 0)
if QApplication.platformName().casefold() != 'offscreen':
# Qt WebEngine embeds a native surface. When that surface first
# appears inside the Metrics QScrollArea, Qt otherwise promotes
# the visible parent chain to native windows, remapping and
# visibly flashing the main window. Create the same hierarchy
# now, while the application window is still hidden. This does
# not load the map document or contact its provider.
self.webView.winId()
def _resolvedTheme(self, theme=None):
"""Resolve the map theme from the authoritative application setting."""
if theme is not None:
return AppStyleSheet.normalizeTheme(theme)
app = APP()
if app is not None and hasattr(app, 'theme'):
return AppStyleSheet.normalizeTheme(app.theme())
else:
base = self.palette().color(QtGui.QPalette.ColorRole.Base)
return AppStyleSheet.Dark if base.lightness() < 128 else AppStyleSheet.Light
def setLocation(self, location, message='', *, loading=False):
"""Replace the displayed coordinate and status message."""
refreshCompleted = self._retainMapDuringRefresh and not loading
previousLocation = self._location
previousCoordinate = (
(
previousLocation.latitude,
previousLocation.longitude,
)
if previousLocation is not None
and previousLocation.latitude is not None
and previousLocation.longitude is not None
else None
)
self._message = str(message or '')
self._loading = bool(loading)
nextCoordinate = (
(location.latitude, location.longitude)
if location is not None
and location.latitude is not None
and location.longitude is not None
else None
)
if not (
self._loading and self._retainMapDuringRefresh and nextCoordinate is None
):
self._location = location
if not self._loading:
self._retainMapDuringRefresh = False
coordinate = (
(self._location.latitude, self._location.longitude)
if self._hasLocation()
else None
)
# A newly observed endpoint is a data transition, not a repaint. Only
# that transition resets user pan/zoom to the canonical location view.
if coordinate is not None and (
coordinate != previousCoordinate or refreshCompleted
):
self._viewRevision += 1
if self._active:
self._ensureSourceLoaded()
self._syncWebState()
self._updateOverlay()
def beginRefresh(self):
"""Overlay progress on the current map during an explicit refresh."""
self._retainMapDuringRefresh = self._hasLocation() and self._mapReady
self._loading = True
self._message = self._loadingText
self._syncWebState()
self._updateOverlay()
def setActive(self, active):
"""Allow map initialization and rendering only while its page is visible."""
self._active = bool(active)
if self._active:
self._ensureSourceLoaded()
self._syncWebState()
self._updateOverlay()
def updateTheme(self, theme=None):
"""Switch vector styles without replacing the persistent map page."""
self._theme = self._resolvedTheme(theme)
self._syncWebState()
def setUnavailableText(self, text):
"""Set the translated fallback shown if the map provider fails."""
self._unavailableText = str(text or '')
self._updateOverlay()
def setLoadingText(self, text):
"""Set the translated message used while map data or HTML is pending."""
self._loadingText = str(text or '')
self._updateOverlay()
def _hasLocation(self):
"""Return whether the current result has a usable coordinate."""
return (
self._location is not None
and self._location.latitude is not None
and self._location.longitude is not None
)
def _ensureSourceLoaded(self):
"""Load the inert local map document once, when its panel becomes active."""
if self._sourceLoaded:
return
self._sourceLoaded = True
self.webView.setUrl(QtCore.QUrl.fromLocalFile(str(self.HtmlPath)))
@QtCore.Slot(bool)
def _loadFinished(self, successful):
"""Publish local-document failures and send the first map state."""
self._documentLoaded = bool(successful)
if successful:
self._rendererFailureHandled = False
else:
self._mapError = self._unavailableText or 'Endpoint map unavailable'
# A renderer crash also completes the outstanding load with False.
# The termination handler already records that more precise cause.
if not self._rendererFailureHandled:
logger.error('failed to load the local endpoint map document')
self._syncWebState()
self._updateOverlay()
@QtCore.Slot()
def _mapBecameReady(self):
"""Show the persistent map after its initial vector style is ready."""
self._mapReady = True
self._mapError = ''
self._syncWebState()
self._updateOverlay()
@QtCore.Slot(str)
def _mapFailed(self, message):
"""Show a fallback if the initial vector map cannot become ready."""
logger.error(f'endpoint map provider error: {message}')
if not self._mapReady:
self._mapError = message or self._unavailableText
self._updateOverlay()
def _renderProcessTerminated(self, status, exitCode):
"""Convert an unexpected Chromium exit into the normal map fallback."""
if self._rendererFailureHandled:
return
self._rendererFailureHandled = True
self._documentLoaded = False
self._mapReady = False
self._mapError = self._unavailableText or 'Endpoint map unavailable'
logger.error(
f'endpoint map render process terminated '
f'({status}, exit code {exitCode})',
)
self._updateOverlay()
@QtCore.Slot(str)
def _openExternalLink(self, link):
"""Open only the static attribution providers used by the map style."""
url = QtCore.QUrl(link)
if (
url.scheme().casefold() == 'https'
and url.host().casefold() in self.TrustedAttributionHosts
):
QtGui.QDesktopServices.openUrl(url)
def _syncWebState(self):
"""Synchronize endpoint and theme state with the persistent web map."""
if not self._documentLoaded:
return
hasLocation = self._hasLocation()
themePalette = AppStyleSheet.paletteForTheme(self._theme)
location, accentColor, font = (
self._location if hasLocation else None,
self.palette().color(QtGui.QPalette.ColorRole.Highlight).name(),
QtGui.QFontInfo(self.font()),
)
state = {
'markerVisible': hasLocation,
'markerLatitude': float(location.latitude) if hasLocation else None,
'markerLongitude': float(location.longitude) if hasLocation else None,
'defaultGeographicZoom': self.DefaultGeographicZoom,
'viewRevision': self._viewRevision,
'darkMode': self._theme == AppStyleSheet.Dark,
'accentColor': accentColor,
'surfaceColor': themePalette['panel_alt'],
'borderColor': themePalette['border'],
'textColor': themePalette['text'],
'mutedTextColor': themePalette['muted'],
'loading': self._loading
or (
self._active
and hasLocation
and not self._mapReady
and not self._mapError
),
'loadingText': self._loadingText or self._message,
'fontFamily': font.family(),
'fontPointSize': font.pointSizeF(),
}
self._lastWebState = state
script = (
f'window.endpointMap && '
f'window.endpointMap.setState({json.dumps(state)});'
)
self._runJavaScript(script)
def _runJavaScript(self, script):
"""Run a state update on the one persistent local map document."""
self.webView.page().runJavaScript(script)
def _updateOverlay(self):
"""Present loading and fallback state without re-stacking the web view."""
initializingMap = (
self._active
and self._hasLocation()
and not self._mapReady
and not self._mapError
)
loading = self._loading or initializingMap
htmlLoadingAvailable = (
self._active and self._sourceLoaded and not self._mapError and loading
)
mapAvailable = (
self._active
and self._hasLocation()
and self._sourceLoaded
and not self._mapError
)
showOverlay = not (htmlLoadingAvailable or mapAvailable)
if loading:
self.statusLabel.setText(self._loadingText or self._message)
elif self._hasLocation() and self._mapError:
self.statusLabel.setText(self._unavailableText)
else:
self.statusLabel.setText(self._message)
self.statusOverlay.setVisible(showOverlay)
self.loadingSpinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text)
if self._active and loading and showOverlay:
self.loadingSpinner.start()
else:
self.loadingSpinner.stop()
class _UnavailableEndpointMapWidget(QWidget):
"""Present endpoint-map state when Qt WebEngine is unavailable."""
def __init__(self, parent=None):
"""Create one persistent, lightweight fallback owned by the map card."""
super().__init__(parent)
self._loading = False
self._active = False
self._message = ''
self._loadingText = ''
self._unavailableText = ''
self.setObjectName('EndpointMapWidget')
self.setMinimumHeight(260)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.statusLabel = AppQLabel(translatable=False, parent=self)
self.statusLabel.setObjectName('EndpointMapPlaceholder')
self.statusLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.statusLabel.setWordWrap(False)
self.loadingSpinner = WaitingSpinner(
self,
center_on_parent=False,
line_length=5,
line_width=2,
radius=4,
lines=12,
)
self.loadingSpinner.setFixedSize(22, 22)
layout = QHBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
layout.addStretch(1)
layout.addWidget(self.loadingSpinner)
layout.addWidget(self.statusLabel)
layout.addStretch(1)
def setLocation(self, _location, message='', *, loading=False):
"""Present the shared endpoint status without attempting map rendering."""
self._message = str(message or '')
self._loading = bool(loading)
self._updatePresentation()
def beginRefresh(self):
"""Show refresh progress until the endpoint service publishes a result."""
self._loading = True
self._message = self._loadingText
self._updatePresentation()
def setActive(self, active):
"""Animate the fallback only while its containing page is visible."""
self._active = bool(active)
self._updatePresentation()
def updateTheme(self, _theme=None):
"""Refresh palette-derived spinner color after an application theme change."""
self._updatePresentation()
def setUnavailableText(self, text):
"""Set the translated explanation shown in place of the map."""
self._unavailableText = str(text or '')
self._updatePresentation()
def setLoadingText(self, text):
"""Set the translated refresh message."""
self._loadingText = str(text or '')
self._updatePresentation()
def _updatePresentation(self):
"""Synchronize the persistent label and visibility-gated spinner."""
if self._loading:
message = self._loadingText or self._message
else:
message = self._message or self._unavailableText
self.statusLabel.setText(message)
self.loadingSpinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text)
if self._active and self._loading:
self.loadingSpinner.start()
else:
self.loadingSpinner.stop()
def _createEndpointMapWidget(parent):
"""Create the rich renderer when available, otherwise its safe fallback."""
if QWebEngineView is not None:
return _EndpointMapWidget(parent)
else:
logger.warning('Qt WebEngine is unavailable; using the endpoint map fallback')
return _UnavailableEndpointMapWidget(parent)
class _ValueRow(QtCore.QObject):
"""Align one endpoint label, selectable value, and optional copy action."""
copied = QtCore.Signal(str)
def __init__(self, name, parent=None, *, copyable=False):
"""Initialize one compact information row."""
super().__init__(parent)
self.nameLabel = AppQLabel(name, parent=parent)
self.nameLabel.setObjectName('EndpointFieldName')
self.valueLabel = AppQLabel(translatable=False, parent=parent)
self.valueLabel.setObjectName('EndpointFieldValue')
self.valueLabel.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
)
self.valueLabel.setWordWrap(True)
self.valueLabel.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred,
)
self.copyButton = None
self._loading = False
self._animationsEnabled = False
self.valueContainer = QWidget(parent)
self.valueContainer.setObjectName('EndpointFieldValueContainer')
self.loadingSpinner = WaitingSpinner(
self.valueContainer,
center_on_parent=False,
line_length=3,
line_width=2,
radius=3,
lines=10,
)
self.loadingSpinner.setFixedSize(16, 16)
valueLayout = QHBoxLayout(self.valueContainer)
valueLayout.setContentsMargins(0, 0, 0, 0)
valueLayout.setSpacing(6)
valueLayout.addWidget(
self.loadingSpinner,
0,
QtCore.Qt.AlignmentFlag.AlignVCenter,
)
valueLayout.addWidget(self.valueLabel, 1)
if copyable:
self.copyButton = AppQPushButton(
icon=bootstrapIcon('files.svg'),
toolTip=_('Copy'),
parent=parent,
)
self.copyButton.setObjectName('EndpointCopyButton')
self.copyButton.setFixedSize(34, 30)
self.copyButton.clicked.connect(self._copy)
def addToLayout(self, layout: QGridLayout, row: int):
"""Insert this row into the card's shared label/value grid."""
if self.copyButton is not None:
nameAlignment = QtCore.Qt.AlignmentFlag.AlignVCenter
else:
nameAlignment = QtCore.Qt.AlignmentFlag.AlignTop
layout.addWidget(
self.nameLabel,
row,
0,
nameAlignment,
)
layout.addWidget(self.valueContainer, row, 1)
if self.copyButton is not None:
layout.addWidget(
self.copyButton,
row,
2,
QtCore.Qt.AlignmentFlag.AlignTop,
)
@QtCore.Slot()
def _copy(self):
"""Copy the currently displayed value through the application clipboard."""
value = self.valueLabel.text().strip()
if value and value != '—':
QApplication.clipboard().setText(value)
self.copied.emit(value)
def setValue(self, value, emptyText='—', *, loading=False):
"""Set the row value and copy availability."""
available = bool(value)
self._loading = bool(loading)
self.valueLabel.setText(str(value or emptyText))
self._syncLoadingAnimation()
if self.copyButton is not None:
self.copyButton.setEnabled(available and not self._loading)
def setAnimationsEnabled(self, enabled):
"""Run the persistent row spinner only while its page is visible."""
self._animationsEnabled = bool(enabled)
self._syncLoadingAnimation()
def _syncLoadingAnimation(self):
"""Apply the current loading and page-visibility state."""
self.loadingSpinner.color = self.valueContainer.palette().color(
QtGui.QPalette.ColorRole.Text
)
if self._loading and self._animationsEnabled:
self.loadingSpinner.start()
else:
self.loadingSpinner.stop()
class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame):
"""Display observed public addresses and approximate egress location."""
def __init__(self, service, parent=None):
"""Build a long-lived view over one independently owned lookup service."""
super().__init__(parent)
self.service = service
self.setObjectName('MetricsSection')
self.setMinimumHeight(360)
self.titleLabel = AppQLabel(_('Proxy Endpoint Information'), parent=self)
self.titleLabel.setObjectName('MetricsSectionTitle')
self.refreshButton = AppQPushButton(
_('Refresh'),
icon=bootstrapIcon('arrow-clockwise.svg'),
toolTip=_('Refresh'),
parent=self,
)
self.refreshButton.clicked.connect(self._refresh)
titleLayout = QHBoxLayout()
titleLayout.setContentsMargins(0, 0, 0, 0)
titleLayout.setSpacing(8)
titleLayout.addWidget(self.titleLabel)
titleLayout.addStretch(1)
titleLayout.addWidget(self.refreshButton)
self.infoCard = QFrame(self)
self.infoCard.setObjectName('MetricCard')
self._unboundedInfoCardMaximumWidth = self.infoCard.maximumWidth()
self.infoCard.setMinimumWidth(380)
self.infoCard.setMaximumWidth(560)
self.infoCard.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Expanding,
)
self.ipv4Row = _ValueRow('IPv4', self.infoCard, copyable=True)
self.ipv6Row = _ValueRow('IPv6', self.infoCard, copyable=True)
self.countryRow = _ValueRow(_('Country'), self.infoCard)
self.locationRow = _ValueRow(_('Approximate Location'), self.infoCard)
self.organizationRow = _ValueRow(_('Organization'), self.infoCard)
self.noteLabel = AppQLabel(
_('Location is estimated from the public IP and may be inaccurate.'),
parent=self.infoCard,
)
self.noteLabel.setObjectName('EndpointNoteLabel')
self.noteLabel.setWordWrap(True)
informationLayout = QGridLayout()
informationLayout.setContentsMargins(0, 0, 0, 0)
informationLayout.setHorizontalSpacing(14)
informationLayout.setVerticalSpacing(10)
informationLayout.setColumnStretch(1, 1)
for rowIndex, row in enumerate(
(
self.ipv4Row,
self.ipv6Row,
self.countryRow,
self.locationRow,
self.organizationRow,
)
):
row.addToLayout(informationLayout, rowIndex)
infoLayout = QVBoxLayout(self.infoCard)
infoLayout.setContentsMargins(16, 14, 16, 14)
infoLayout.setSpacing(14)
infoLayout.addLayout(informationLayout)
infoLayout.addStretch(1)
infoLayout.addWidget(self.noteLabel)
self.mapCard = QFrame(self)
self.mapCard.setObjectName('MetricCard')
self.mapTitleLabel = AppQLabel(_('Approximate Location'), parent=self.mapCard)
self.mapTitleLabel.setObjectName('MetricCardTitle')
self.mapWidget = _createEndpointMapWidget(self.mapCard)
mapLayout = QVBoxLayout(self.mapCard)
mapLayout.setContentsMargins(14, 12, 14, 12)
mapLayout.setSpacing(8)
mapLayout.addWidget(self.mapTitleLabel)
mapLayout.addWidget(self.mapWidget, 1)
self.bodyLayout = QBoxLayout(QBoxLayout.Direction.LeftToRight)
self.bodyLayout.setContentsMargins(0, 0, 0, 0)
self.bodyLayout.setSpacing(12)
self.bodyLayout.addWidget(self.infoCard, 1)
self.bodyLayout.addWidget(self.mapCard, 2)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(10)
layout.addLayout(titleLayout)
layout.addLayout(self.bodyLayout)
self.service.stateChanged.connect(self._updatePresentation)
self.service.resultChanged.connect(self._updatePresentation)
self.retranslate()
@QtCore.Slot()
def _refresh(self):
"""Refresh endpoint data while retaining a completed map underneath."""
self.mapWidget.beginRefresh()
self.service.refresh()
if self.service.state is not EndpointInfoState.Loading:
self._updatePresentation()
@QtCore.Slot()
@QtCore.Slot(object)
def _updatePresentation(self, _value=None):
"""Render one immutable service snapshot without starting network work."""
state = self.service.state
result: EndpointInfo = self.service.result
location = result.location
self.refreshButton.setEnabled(
state in (EndpointInfoState.Ready, EndpointInfoState.Failed)
)
ipv4Loading, ipv6Loading, mapLoading = (
state is EndpointInfoState.Loading and not result.ipv4Resolved,
state is EndpointInfoState.Loading and not result.ipv6Resolved,
state is EndpointInfoState.Loading,
)
locationLoading = mapLoading and not result.locationResolved
self.ipv4Row.setValue(
result.ipv4,
_('Detecting...') if ipv4Loading else _('Not available'),
loading=ipv4Loading,
)
self.ipv6Row.setValue(
result.ipv6,
_('Detecting...') if ipv6Loading else _('Not available'),
loading=ipv6Loading,
)
country = location.countryName
if country and location.countryCode:
country = f'{country} ({location.countryCode})'
elif location.countryCode:
country = location.countryCode
self.countryRow.setValue(
country,
_('Detecting...') if locationLoading else _('Unknown'),
loading=locationLoading,
)
self.locationRow.setValue(
location.displayName,
_('Detecting...') if locationLoading else _('Not available'),
loading=locationLoading,
)
self.organizationRow.setValue(
location.organization,
_('Detecting...') if locationLoading else _('Not available'),
loading=locationLoading,
)
if mapLoading:
mapMessage = _('Detecting...')
elif location.latitude is None or location.longitude is None:
mapMessage = _('Approximate location unavailable')
else:
mapMessage = ''
self.mapWidget.setLocation(location, mapMessage, loading=mapLoading)
self._setLoadingAnimationsEnabled(self.isVisible())
def _setLoadingAnimationsEnabled(self, enabled):
"""Start only the persistent loading indicators currently on screen."""
for row in (
self.ipv4Row,
self.ipv6Row,
self.countryRow,
self.locationRow,
self.organizationRow,
):
row.setAnimationsEnabled(enabled)
def themeChangedCallback(self, theme=None):
"""Switch the embedded map to the resolved application theme."""
self.mapWidget.updateTheme(theme)
def resizeEvent(self, event):
"""Stack cards only when side-by-side fields would become unreadable."""
direction = (
QBoxLayout.Direction.TopToBottom
if event.size().width() < 760
else QBoxLayout.Direction.LeftToRight
)
if self.bodyLayout.direction() is not direction:
self.bodyLayout.setDirection(direction)
self.infoCard.setMaximumWidth(
self._unboundedInfoCardMaximumWidth
if direction is QBoxLayout.Direction.TopToBottom
else 560
)
super().resizeEvent(event)
def showEvent(self, event):
"""Resume the lightweight activity indicator only while visible."""
super().showEvent(event)
self.mapWidget.setActive(True)
self._setLoadingAnimationsEnabled(True)
def hideEvent(self, event):
"""Avoid animating a spinner on a hidden Metrics page."""
self.mapWidget.setActive(False)
self._setLoadingAnimationsEnabled(False)
super().hideEvent(event)
def retranslate(self):
"""Refresh state-dependent endpoint presentation copy."""
self.mapWidget.setLoadingText(_('Detecting...'))
self.mapWidget.setUnavailableText(_('Approximate location unavailable'))
self._updatePresentation()