# Copyright (C) 2024–present Loren Eteval & contributors # # 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 . """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()