diff --git a/Furious/Data/maplibre/EndpointMap.html b/Furious/Data/maplibre/EndpointMap.html index bc90604..ac463a6 100644 --- a/Furious/Data/maplibre/EndpointMap.html +++ b/Furious/Data/maplibre/EndpointMap.html @@ -20,11 +20,54 @@ :root { --endpoint-attribution-background: rgb(255 255 255 / 92%); --endpoint-attribution-foreground: #202020; + --endpoint-loading-background: rgb(255 255 255 / 32%); + --endpoint-loading-foreground: #202020; + --endpoint-font-family: sans-serif; + --endpoint-font-size: 11pt; } :root[data-theme="dark"] { --endpoint-attribution-background: rgb(24 30 39 / 92%); --endpoint-attribution-foreground: #f5f5f5; + --endpoint-loading-background: rgb(16 21 28 / 38%); + --endpoint-loading-foreground: #f5f5f5; + } + + #endpoint-loading-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: none; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--endpoint-loading-foreground); + background: var(--endpoint-loading-background); + cursor: wait; + font-family: var(--endpoint-font-family); + font-size: var(--endpoint-font-size); + font-weight: 400; + user-select: none; + } + + #endpoint-loading-overlay[data-visible="true"] { + display: flex; + } + + .endpoint-loading-spinner { + width: 16px; + height: 16px; + box-sizing: border-box; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: endpoint-spinner-rotation 0.8s linear infinite; + } + + @keyframes endpoint-spinner-rotation { + to { + transform: rotate(360deg); + } } .endpoint-marker { @@ -67,6 +110,14 @@
+
+ + +
diff --git a/Furious/Data/maplibre/EndpointMap.js b/Furious/Data/maplibre/EndpointMap.js index 4a92550..ef12f0d 100644 --- a/Furious/Data/maplibre/EndpointMap.js +++ b/Furious/Data/maplibre/EndpointMap.js @@ -8,6 +8,17 @@ let appliedRevision = -1; let activeStyle = ''; let initialReadyReported = false; + const loadingOverlay = document.getElementById('endpoint-loading-overlay'); + const loadingText = document.getElementById('endpoint-loading-text'); + + const applyLoadingState = () => { + const loading = Boolean(state && state.loading); + loadingOverlay.dataset.visible = String(loading); + loadingOverlay.setAttribute('aria-hidden', String(!loading)); + loadingText.textContent = loading && state.loadingText + ? state.loadingText + : ''; + }; const reportError = (event) => { const message = event && event.error && event.error.message @@ -46,7 +57,9 @@ }; const applyState = () => { - if (!map || !state) { + applyLoadingState(); + + if (!state) { return; } @@ -58,6 +71,19 @@ '--endpoint-accent', state.accentColor ); + document.documentElement.style.setProperty( + '--endpoint-font-family', + JSON.stringify(state.fontFamily || 'sans-serif') + ); + document.documentElement.style.setProperty( + '--endpoint-font-size', + `${state.fontPointSize || 11}pt` + ); + + if (!map) { + return; + } + const nextStyle = state.darkMode ? state.darkStyleUrl : state.lightStyleUrl; @@ -79,7 +105,7 @@ }; const createMap = () => { - if (map || !state) { + if (map || !state || !state.markerVisible) { return; } @@ -105,9 +131,10 @@ applyState(); }; - window.furiousEndpointMap = { + window.endpointMap = { setState(nextState) { state = nextState; + applyLoadingState(); createMap(); applyState(); }, diff --git a/Furious/Qt/StyleSheets/Base.py b/Furious/Qt/StyleSheets/Base.py index 71a5289..4a340b7 100644 --- a/Furious/Qt/StyleSheets/Base.py +++ b/Furious/Qt/StyleSheets/Base.py @@ -285,15 +285,6 @@ def baseStyleSheet(palette): font-weight: 600; }} - QLabel#EndpointStatusLabel {{ - color: {palette['text_strong']}; - font-weight: 600; - }} - - QWidget#EndpointStatusWidget {{ - background-color: transparent; - }} - QLabel#EndpointFieldName {{ color: {palette['muted']}; }} diff --git a/Furious/Service/EndpointInfoService.py b/Furious/Service/EndpointInfoService.py index 3fd58fb..b086599 100644 --- a/Furious/Service/EndpointInfoService.py +++ b/Furious/Service/EndpointInfoService.py @@ -383,8 +383,8 @@ class EndpointInfoService(QtCore.QObject): self._requestInFlight = False self._countryHint = '' self.httpClient.cancelAll() - self._publishResult(EndpointInfo()) self._setState(EndpointInfoState.Loading) + self._publishResult(EndpointInfo()) self._startLookup() def requestIfNeeded(self): diff --git a/Furious/Widget/EndpointInfoWidget.py b/Furious/Widget/EndpointInfoWidget.py index dbaca29..e17727c 100644 --- a/Furious/Widget/EndpointInfoWidget.py +++ b/Furious/Widget/EndpointInfoWidget.py @@ -19,7 +19,7 @@ from __future__ import annotations -from Furious.Frozenlib import APP, DATA_DIR, Mixins +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 @@ -125,6 +125,7 @@ class _EndpointMapWidget(QWidget): self._mapError = '' self._viewRevision = 0 self._lastWebState = {} + self._retainMapDuringRefresh = False self._theme = self._resolvedTheme() @@ -132,18 +133,18 @@ class _EndpointMapWidget(QWidget): self.setMinimumHeight(260) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.placeholderWidget = QWidget(self) - self.placeholderWidget.setAutoFillBackground(True) + self.statusOverlay = QWidget(self) + self.statusOverlay.setAutoFillBackground(True) - self.placeholderLabel = AppQLabel( + self.statusLabel = AppQLabel( translatable=False, - parent=self.placeholderWidget, + parent=self.statusOverlay, ) - self.placeholderLabel.setObjectName('EndpointMapPlaceholder') - self.placeholderLabel.setWordWrap(True) + self.statusLabel.setObjectName('EndpointMapPlaceholder') + self.statusLabel.setWordWrap(False) self.loadingSpinner = WaitingSpinner( - self.placeholderWidget, + self.statusOverlay, center_on_parent=False, line_length=5, line_width=2, @@ -152,13 +153,13 @@ class _EndpointMapWidget(QWidget): ) self.loadingSpinner.setFixedSize(22, 22) - placeholderLayout = QHBoxLayout(self.placeholderWidget) - placeholderLayout.setContentsMargins(12, 12, 12, 12) - placeholderLayout.setSpacing(8) - placeholderLayout.addStretch(1) - placeholderLayout.addWidget(self.loadingSpinner) - placeholderLayout.addWidget(self.placeholderLabel) - placeholderLayout.addStretch(1) + 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 @@ -206,13 +207,22 @@ class _EndpointMapWidget(QWidget): self._renderProcessTerminated ) - self.overlayLayout = QGridLayout(self) - self.overlayLayout.setContentsMargins(0, 0, 0, 0) - self.overlayLayout.addWidget(self.webView, 0, 0) - self.overlayLayout.addWidget(self.placeholderWidget, 0, 0) + layout = QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.webView, 0, 0) + layout.addWidget(self.statusOverlay, 0, 0) - self._presentedWidget = self.placeholderWidget - self.placeholderWidget.raise_() + if ( + PLATFORM == 'Windows' + and 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.""" @@ -230,6 +240,7 @@ class _EndpointMapWidget(QWidget): def setLocation(self, location, message='', *, loading=False): """Replace the displayed coordinate and status message.""" + refreshCompleted = self._retainMapDuringRefresh and not loading previousLocation = self._location previousCoordinate = ( ( @@ -242,34 +253,61 @@ class _EndpointMapWidget(QWidget): else None ) - self._location = location 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 = ( - (location.latitude, location.longitude) if self._hasLocation() else None + (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: + if coordinate is not None and ( + coordinate != previousCoordinate or refreshCompleted + ): self._viewRevision += 1 - if self._active and self._hasLocation(): + if self._active: self._ensureSourceLoaded() self._syncWebState() - self._updateVisibleWidget() + 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 and self._hasLocation(): + if self._active: self._ensureSourceLoaded() self._syncWebState() - self._updateVisibleWidget() + self._updateOverlay() def updateTheme(self, theme=None): """Switch vector styles without replacing the persistent map page.""" @@ -279,12 +317,12 @@ class _EndpointMapWidget(QWidget): def setUnavailableText(self, text): """Set the translated fallback shown if the map provider fails.""" self._unavailableText = str(text or '') - self._updateVisibleWidget() + self._updateOverlay() def setLoadingText(self, text): """Set the translated message used while map data or HTML is pending.""" self._loadingText = str(text or '') - self._updateVisibleWidget() + self._updateOverlay() def _hasLocation(self): """Return whether the current result has a usable coordinate.""" @@ -295,7 +333,7 @@ class _EndpointMapWidget(QWidget): ) def _ensureSourceLoaded(self): - """Load the local MapLibre document once, on first visible coordinate.""" + """Load the inert local map document once, when its panel becomes active.""" if self._sourceLoaded: return @@ -313,14 +351,15 @@ class _EndpointMapWidget(QWidget): logger.error('failed to load the local endpoint map document') self._syncWebState() - self._updateVisibleWidget() + self._updateOverlay() @QtCore.Slot() def _mapBecameReady(self): """Show the persistent map after its initial vector style is ready.""" self._mapReady = True self._mapError = '' - self._updateVisibleWidget() + self._syncWebState() + self._updateOverlay() @QtCore.Slot(str) def _mapFailed(self, message): @@ -329,7 +368,7 @@ class _EndpointMapWidget(QWidget): if not self._mapReady: self._mapError = message or self._unavailableText - self._updateVisibleWidget() + self._updateOverlay() @QtCore.Slot(QWebEnginePage.RenderProcessTerminationStatus, int) def _renderProcessTerminated(self, status, exitCode): @@ -342,7 +381,7 @@ class _EndpointMapWidget(QWidget): f'({status}, exit code {exitCode})', ) - self._updateVisibleWidget() + self._updateOverlay() @QtCore.Slot(str) def _openExternalLink(self, link): @@ -357,29 +396,41 @@ class _EndpointMapWidget(QWidget): def _syncWebState(self): """Synchronize endpoint and theme state with the persistent web map.""" - if not self._documentLoaded or not self._hasLocation(): + if not self._documentLoaded: return - location = self._location + hasLocation = self._hasLocation() + location = self._location if hasLocation else None accentColor = self.palette().color(QtGui.QPalette.ColorRole.Highlight).name() + font = QtGui.QFontInfo(self.font()) state = { - 'markerVisible': True, - 'markerLatitude': float(location.latitude), - 'markerLongitude': float(location.longitude), + '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, 'lightStyleUrl': self.LightStyle.value, 'darkStyleUrl': self.DarkStyle.value, 'accentColor': accentColor, + '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.furiousEndpointMap && ' - f'window.furiousEndpointMap.setState({json.dumps(state)});' + f'window.endpointMap && ' + f'window.endpointMap.setState({json.dumps(state)});' ) self._runJavaScript(script) @@ -388,47 +439,40 @@ class _EndpointMapWidget(QWidget): """Run a state update on the one persistent local map document.""" self.webView.page().runJavaScript(script) - def _updateVisibleWidget(self): - """Show the live map only after its initial vector style is ready.""" + 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 ) - - showMap = ( - self._active - and not self._loading - and self._hasLocation() - and self._documentLoaded - and self._mapReady - ) - - visibleWidget = self.webView if showMap else self.placeholderWidget - - # Keep the persistent Chromium surface in the widget hierarchy and - # raise the opaque placeholder above it for loading/fallback states. - # QStackedLayout changes QWebEngineView visibility even in StackAll; - # that recreates its Windows compositor surface and can momentarily - # flash the containing top-level window. - if self._presentedWidget is not visibleWidget: - visibleWidget.raise_() - - self._presentedWidget = visibleWidget - 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.placeholderLabel.setText(self._loadingText or self._message) + self.statusLabel.setText(self._loadingText or self._message) elif self._hasLocation() and self._mapError: - self.placeholderLabel.setText(self._unavailableText) + self.statusLabel.setText(self._unavailableText) else: - self.placeholderLabel.setText(self._message) + 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 not showMap: + if self._active and loading and showOverlay: self.loadingSpinner.start() else: self.loadingSpinner.stop() @@ -575,7 +619,7 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): toolTip=_('Refresh'), parent=self, ) - self.refreshButton.clicked.connect(self.service.refresh) + self.refreshButton.clicked.connect(self._refresh) titleLayout = QHBoxLayout() titleLayout.setContentsMargins(0, 0, 0, 0) @@ -596,29 +640,6 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): QSizePolicy.Policy.Expanding, ) - self.statusWidget = QWidget(self.infoCard) - self.statusWidget.setObjectName('EndpointStatusWidget') - - self.statusLabel = AppQLabel(translatable=False, parent=self.statusWidget) - self.statusLabel.setObjectName('EndpointStatusLabel') - self.statusLabel.setWordWrap(True) - - self.spinner = WaitingSpinner( - self.statusWidget, - center_on_parent=False, - line_length=5, - line_width=2, - radius=4, - lines=12, - ) - self.spinner.setFixedSize(22, 22) - - statusLayout = QHBoxLayout(self.statusWidget) - statusLayout.setContentsMargins(0, 0, 0, 0) - statusLayout.setSpacing(8) - statusLayout.addWidget(self.spinner) - statusLayout.addWidget(self.statusLabel, 1) - self.ipv4Row = _ValueRow('IPv4', self.infoCard, copyable=True) self.ipv6Row = _ValueRow('IPv6', self.infoCard, copyable=True) self.countryRow = _ValueRow(_('Country'), self.infoCard) @@ -652,7 +673,6 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): infoLayout = QVBoxLayout(self.infoCard) infoLayout.setContentsMargins(16, 14, 16, 14) infoLayout.setSpacing(14) - infoLayout.addWidget(self.statusWidget) infoLayout.addLayout(informationLayout) infoLayout.addStretch(1) infoLayout.addWidget(self.noteLabel) @@ -688,6 +708,15 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): 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): @@ -700,34 +729,6 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): state in (EndpointInfoState.Ready, EndpointInfoState.Failed) ) - if state is EndpointInfoState.Loading: - self.statusWidget.setVisible(True) - self.statusLabel.setText(_('Detecting...')) - self.spinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text) - - if self.isVisible(): - self.spinner.start() - else: - self.spinner.stop() - else: - self.spinner.stop() - self.statusWidget.setVisible(state is not EndpointInfoState.Ready) - - if state is EndpointInfoState.Disconnected: - self.statusLabel.setText( - _('Connect to a proxy to view endpoint information.') - ) - elif state is EndpointInfoState.Disabled: - self.statusLabel.setText(_('Endpoint inspection is disabled.')) - elif state is EndpointInfoState.Connecting: - self.statusLabel.setText(_('Connecting...')) - elif state is EndpointInfoState.Failed: - self.statusLabel.setText( - _('Proxy endpoint information is unavailable.') - ) - else: - self.statusLabel.clear() - ipv4Loading, ipv6Loading, mapLoading = ( state is EndpointInfoState.Loading and not result.ipv4Resolved, state is EndpointInfoState.Loading and not result.ipv6Resolved, @@ -821,13 +822,9 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): self.mapWidget.setActive(True) self._setLoadingAnimationsEnabled(True) - if self.service.state is EndpointInfoState.Loading: - self.spinner.start() - def hideEvent(self, event): """Avoid animating a spinner on a hidden Metrics page.""" self.mapWidget.setActive(False) - self.spinner.stop() self._setLoadingAnimationsEnabled(False) super().hideEvent(event)