# 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 exported server configurations as centered QR codes.""" from __future__ import annotations from Furious.Frozenlib import APPLICATION_NAME from Furious.Models import ServerProfile from Furious.Plugins import exportConfiguration from Furious.Repository import Storage from Furious.Qt import ( AppQMainWindow, AppQTabWidget, connectWeakly, ) from Furious.Qt import gettext as _ from PySide6 import QtCore from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget import segno import logging from collections.abc import Iterable, Sequence from dataclasses import dataclass __all__ = [ 'MAXIMUM_QR_EXPORT_PROFILES', 'QRCodeExportItem', 'QRCodeWindow', 'captureQRCodeExportItems', ] _QR_ERROR_CORRECTION = 'H' _QR_PAGE_MARGIN = 28 MAXIMUM_QR_EXPORT_PROFILES = 50 logger = logging.getLogger(__name__) def createQRCodeImage(data: str) -> QImage: """Render opaque text as one grayscale pixel per QR module.""" if not isinstance(data, str) or not data: raise ValueError('QR code data must be a non-empty string') qrcode = segno.make_qr(data, error=_QR_ERROR_CORRECTION) border = qrcode.default_border_size width, height = qrcode.symbol_size(scale=1, border=border) if width != height: raise ValueError('QR code matrix must be square') image = QImage(width, height, QImage.Format.Format_Grayscale8) image.fill(255) for y, row in enumerate(qrcode.matrix_iter(scale=1, border=border)): image.scanLine(y)[:width] = bytes(0 if module else 255 for module in row) return image @dataclass(frozen=True) class QRCodeExportItem: """Capture one profile exactly as selected for later QR export.""" position: int remark: str profile: ServerProfile def captureQRCodeExportItems( indexes: Iterable[int], profiles: Sequence[ServerProfile] | None = None, ) -> tuple[QRCodeExportItem, ...]: """Snapshot at most the first eligible selected profiles in caller order.""" sourceProfiles = Storage.UserServers() if profiles is None else profiles items = [] for rawIndex in indexes: if len(items) >= MAXIMUM_QR_EXPORT_PROFILES: break try: index = int(rawIndex) except (TypeError, ValueError): continue if index < 0 or index >= len(sourceProfiles): continue profile = sourceProfiles[index] try: snapshot = profile.deepcopy() except Exception as ex: # Any non-exit exceptions # A malformed plugin profile must not abort the remaining export. logger.warning( 'unable to snapshot profile %d for QR presentation (%s)', index + 1, type(ex).__name__, ) continue items.append( QRCodeExportItem( position=index + 1, remark=str(profile.itemRemark), profile=snapshot, ) ) return tuple(items) class _QRCodePage(QWidget): """Own and responsively present one logical QR image inside a tab page.""" def __init__(self, image: QImage, parent=None): """Create a centered, margin-managed QR presentation page.""" super().__init__(parent) if image.isNull() or image.width() != image.height(): raise ValueError('QR code image must be a non-null square') self.setObjectName('QRCodePage') self._sourceImage = QImage(image) self._displayScale = 0 self.qrLabel = QLabel(parent=self) self.qrLabel.setObjectName('QRCodeImage') self.qrLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.qrLabel.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding, ) self._layout = QVBoxLayout(self) self._layout.setContentsMargins( _QR_PAGE_MARGIN, _QR_PAGE_MARGIN, _QR_PAGE_MARGIN, _QR_PAGE_MARGIN, ) self._layout.setSpacing(0) self._layout.addWidget( self.qrLabel, 1, QtCore.Qt.AlignmentFlag.AlignCenter, ) self._refreshPixmap() def sourceImage(self) -> QImage: """Return an implicitly shared copy of the logical QR image.""" return QImage(self._sourceImage) def moduleSpan(self) -> int: """Return the QR width expressed in modules including its quiet zone.""" return self._sourceImage.width() def displayScale(self) -> int: """Return the integer scale currently used by the presentation pixmap.""" return self._displayScale def _targetScale(self) -> int: """Return the largest integer scale that fits this page.""" available = self._layout.contentsRect().size() availableSide = min(available.width(), available.height()) if availableSide <= 0: return 1 return max(1, availableSide // self.moduleSpan()) def _refreshPixmap(self): """Scale the cached image without regenerating or smoothing QR modules.""" displayScale = self._targetScale() if displayScale == self._displayScale: return self._displayScale = displayScale targetSide = self.moduleSpan() * displayScale self.qrLabel.setPixmap( QPixmap.fromImage( self._sourceImage.scaled( targetSide, targetSide, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.FastTransformation, ) ) ) def resizeEvent(self, event): """Refresh only the displayed raster when the available page size changes.""" super().resizeEvent(event) self._refreshPixmap() def showEvent(self, event): """Fit the QR pixmap after the tab layout receives its final geometry.""" super().showEvent(event) # A newly selected tab has its final page geometry before Qt lays out its # hidden children. Activate this page synchronously so its first rendered # pixmap uses the available tab area instead of the construction-time size. self._layout.activate() self._refreshPixmap() class QRCodeWindow(AppQMainWindow): """Incrementally present exported configurations in QR-code tabs.""" DEFAULT_WINDOW_SIZE = QtCore.QSize(720, 680) MINIMUM_WINDOW_SIZE = QtCore.QSize(520, 520) def __init__(self, *args, **kwargs): """Initialize the transient, resizable QR-code window.""" super().__init__(*args, **kwargs) self.setWindowTitle(_(APPLICATION_NAME)) self.setMinimumSize(self.MINIMUM_WINDOW_SIZE) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True) self._exportItems = tuple() self._exportProcessedCount = 0 self._exportGeneratedCount = 0 self._exporting = False self._exportTimer = QtCore.QTimer(self) self._exportTimer.setSingleShot(True) connectWeakly( self._exportTimer.timeout, self, 'processNextExportItem', sender=self._exportTimer, ) self.tabWidget = AppQTabWidget(parent=self, translatable=False) self.tabWidget.setTabsClosable(True) self.tabWidget.setElideMode(QtCore.Qt.TextElideMode.ElideRight) connectWeakly( self.tabWidget.tabCloseRequested, self, 'handleTabCloseRequested', ) self.setCentralWidget(self.tabWidget) def tabCount(self) -> int: """Return the number of successfully generated QR-code tabs.""" return self.tabWidget.count() def isExporting(self) -> bool: """Return whether incremental QR generation still owns pending items.""" return self._exporting def exportProcessedCount(self) -> int: """Return the number of items attempted by the current export.""" return self._exportProcessedCount def exportGeneratedCount(self) -> int: """Return the number of tabs generated by the current export.""" return self._exportGeneratedCount def _clearTabs(self): """Remove and destroy every page currently owned by the tab widget.""" while self.tabWidget.count(): page = self.tabWidget.widget(0) self.tabWidget.removeTab(0) if page is not None: page.deleteLater() def appendExportItem(self, item: QRCodeExportItem) -> bool: """Generate and append one captured profile without affecting its siblings.""" try: uri = exportConfiguration(item.profile) except Exception as ex: # Any non-exit exceptions logger.warning( 'unable to export profile %d for QR presentation (%s)', item.position, type(ex).__name__, ) return False if not uri: return False try: image = createQRCodeImage(uri) except (segno.DataOverflowError, ValueError) as ex: logger.warning( 'unable to create QR code for profile %d (%s)', item.position, type(ex).__name__, ) return False except Exception as ex: # Any non-exit exceptions logger.warning( 'unable to create QR code for profile %d (%s)', item.position, type(ex).__name__, ) return False page = _QRCodePage(image, parent=self.tabWidget) title = f'{item.position} - {item.remark}' tabIndex = self.tabWidget.addTab(page, title) self.tabWidget.setTabToolTip(tabIndex, title) return True def initTabByIndex(self, indexes: list[int]): """Synchronously initialize capped tabs for compatibility callers.""" self.cancelExport() self._clearTabs() for item in captureQRCodeExportItems(indexes): self.appendExportItem(item) def startExportByIndex(self, indexes: Iterable[int]): """Start one bounded, window-owned incremental QR export.""" items = captureQRCodeExportItems(tuple(indexes)) self.cancelExport() self._clearTabs() self._exportProcessedCount = 0 self._exportGeneratedCount = 0 if not items: self.deleteLater() return None if len(items) == 1: if self.appendExportItem(items[0]): self._exportProcessedCount = 1 self._exportGeneratedCount = 1 self.show() else: self._exportProcessedCount = 1 self.deleteLater() return self self._exportItems = items self._exporting = True self.show() self._exportTimer.start(0) return self def processNextExportItem(self): """Attempt one captured profile, then yield to the Qt event loop.""" if not self._exporting: return if self._exportProcessedCount >= len(self._exportItems): self.finishExport() return item = self._exportItems[self._exportProcessedCount] generated = self.appendExportItem(item) self._exportProcessedCount += 1 self._exportGeneratedCount += int(generated) if self._exportProcessedCount >= len(self._exportItems): self.finishExport() return self._exportTimer.start(0) def finishExport(self): """Release completed export state and discard an empty result window.""" if not self._exporting: return generatedCount = self._exportGeneratedCount self._exportTimer.stop() self._exportItems = tuple() self._exporting = False if generatedCount == 0: self.close() def cancelExport(self): """Cancel pending work without removing tabs already generated.""" self._exportTimer.stop() self._exportItems = tuple() self._exporting = False def closeEvent(self, event): """Cancel an active export after Qt accepts closing this window.""" super().closeEvent(event) if event.isAccepted(): self.cancelExport() @QtCore.Slot(int) def handleTabCloseRequested(self, index: int): """Destroy one requested page and close after the final tab is removed.""" page = self.tabWidget.widget(index) if page is None: return self.tabWidget.removeTab(index) page.deleteLater() if self.tabWidget.count() == 0: self.close()