mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-26 00:38:06 +03:00
457 lines
13 KiB
Python
457 lines
13 KiB
Python
# 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 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()
|