diff --git a/BlocksScreen/lib/panels/widgets/amuWidgets.py b/BlocksScreen/lib/panels/widgets/amuWidgets.py index 46aa40c8..dd02d0b9 100644 --- a/BlocksScreen/lib/panels/widgets/amuWidgets.py +++ b/BlocksScreen/lib/panels/widgets/amuWidgets.py @@ -163,26 +163,11 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: if self.status in [GateStatus.AVAILABLE, GateStatus.AVAILABLE_FROM_BUFFER] else self._unloaded_icon ) - scaled = icon.scaled( - icon_size, - icon_size, - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + scaled = BlocksPixmap.get(icon, QtCore.QSize(icon_size, icon_size)) x = (self.width() - scaled.width()) // 2 y = int((self.height() - scaled.height()) // 1.1) - tinted = QtGui.QPixmap(scaled.size()) - tinted.fill(QtCore.Qt.GlobalColor.transparent) - p2 = QtGui.QPainter(tinted) - p2.drawPixmap(0, 0, scaled) - p2.setCompositionMode(QtGui.QPainter.CompositionMode.CompositionMode_SourceIn) - p2.fillRect(tinted.rect(), white) - p2.end() - painter.drawPixmap(x, y, tinted) - - tinted = QtGui.QPixmap(scaled.size()) - tinted.fill(QtCore.Qt.GlobalColor.transparent) + painter.drawPixmap(x, y, BlocksPixmap.tinted(scaled, white)) painter.setPen(QtCore.Qt.PenStyle.NoPen) painter.end() diff --git a/BlocksScreen/lib/panels/widgets/cancelPage.py b/BlocksScreen/lib/panels/widgets/cancelPage.py index 8188a811..84d74039 100644 --- a/BlocksScreen/lib/panels/widgets/cancelPage.py +++ b/BlocksScreen/lib/panels/widgets/cancelPage.py @@ -1,11 +1,14 @@ +import logging +import typing + +from lib.moonrakerComm import MoonWebSocket from lib.utils.blocks_button import BlocksCustomButton from lib.utils.blocks_frame import BlocksCustomFrame from lib.utils.blocks_label import BlocksLabel from lib.utils.blocks_pixmap import BlocksPixmap, Icon from PyQt6 import QtCore, QtGui, QtWidgets -import typing -from lib.moonrakerComm import MoonWebSocket +logger = logging.getLogger(__name__) class CancelPage(QtWidgets.QWidget): @@ -76,12 +79,7 @@ def set_pixmap(self, pixmap: QtGui.QPixmap) -> None: # Scene rectangle (available display area) graphics_rect = self.cf_thumbnail.rect().toRectF() - # Scale pixmap preserving aspect ratio - pixmap = pixmap.scaled( - graphics_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + pixmap = BlocksPixmap.get(pixmap, graphics_rect) adjusted_x = (graphics_rect.width() - pixmap.width()) / 2.0 adjusted_y = (graphics_rect.height() - pixmap.height()) / 2.0 @@ -105,14 +103,10 @@ def _show_screen_thumbnail(self, dict): last_thumb = QtGui.QPixmap.fromImage(thumbnails[-1]) if last_thumb.isNull(): - last_thumb = QtGui.QPixmap( - "BlocksScreen/lib/ui/resources/media/logoblocks400x300.png" - ) + last_thumb = BlocksPixmap.source(Icon.LOGO_BLOCKS) except Exception as e: - print(e) - last_thumb = QtGui.QPixmap( - "BlocksScreen/lib/ui/resources/media/logoblocks400x300.png" - ) + logger.warning("No job thumbnail, falling back to the logo: %s", e) + last_thumb = BlocksPixmap.source(Icon.LOGO_BLOCKS) self.set_pixmap(last_thumb) def _setupUI(self) -> None: diff --git a/BlocksScreen/lib/panels/widgets/confirmPage.py b/BlocksScreen/lib/panels/widgets/confirmPage.py index b0ecebde..d7c98434 100644 --- a/BlocksScreen/lib/panels/widgets/confirmPage.py +++ b/BlocksScreen/lib/panels/widgets/confirmPage.py @@ -27,7 +27,9 @@ def __init__(self, parent) -> None: self.setMouseTracking(True) self.setAttribute(QtCore.Qt.WidgetAttribute.WA_AcceptTouchEvents, True) self.thumbnail: QtGui.QImage = self._blocksthumbnail - self._thumbnails: typing.List = [] + self._thumbnail_key: int = 0 + self._thumbnail_cache: QtGui.QPixmap = QtGui.QPixmap() + self._thumbnails: list = [] self.directory = "gcodes" self.filename = "" self.confirm_button.clicked.connect( @@ -108,6 +110,13 @@ def hide(self): self.filename = "" return super().hide() + def _thumbnail_as_pixmap(self) -> QtGui.QPixmap: + """Convert the thumbnail QImage once per image, not once per paint.""" + if self.thumbnail.cacheKey() != self._thumbnail_key: + self._thumbnail_key = self.thumbnail.cacheKey() + self._thumbnail_cache = QtGui.QPixmap.fromImage(self.thumbnail) + return self._thumbnail_cache + def paintEvent(self, event: QtGui.QPaintEvent) -> None: """Re-implemented method, paint widget""" if not self.isVisible(): @@ -120,12 +129,7 @@ def paintEvent(self, event: QtGui.QPaintEvent) -> None: # Scene rectangle (available display area) graphics_rect = self.cf_thumbnail.rect().toRectF() - # Scale pixmap preserving aspect ratio - pixmap = QtGui.QPixmap.fromImage(self.thumbnail).scaled( - graphics_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + pixmap = BlocksPixmap.get(self._thumbnail_as_pixmap(), graphics_rect) # Centering offsets adjusted_x = (graphics_rect.width() - pixmap.width()) / 2.0 @@ -316,6 +320,4 @@ def _setupUI(self) -> None: QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter, ) self.verticalLayout_4.addLayout(self.cf_content_vertical_layout) - self._blocksthumbnail = QtGui.QImage( - "BlocksScreen/lib/ui/resources/media/logoblocks400x300.png" - ) + self._blocksthumbnail = QtGui.QImage(str(Icon.LOGO_BLOCKS)) diff --git a/BlocksScreen/lib/panels/widgets/optionCardWidget.py b/BlocksScreen/lib/panels/widgets/optionCardWidget.py index d501b94d..301dbc2e 100644 --- a/BlocksScreen/lib/panels/widgets/optionCardWidget.py +++ b/BlocksScreen/lib/panels/widgets/optionCardWidget.py @@ -59,13 +59,13 @@ def enable_button(self) -> None: def set_card_icon(self, pixmap: QtGui.QPixmap) -> None: """Set widget icon""" - scaled = pixmap.scaled( - 300, - 300, - QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, + self.option_icon.setPixmap( + BlocksPixmap.get( + pixmap, + QtCore.QSize(300, 300), + QtCore.Qt.AspectRatioMode.IgnoreAspectRatio, + ) ) - self.option_icon.setPixmap(scaled) self.repaint() def set_card_text(self, text: str) -> None: diff --git a/BlocksScreen/lib/ui/resources/graphic_resources.qrc b/BlocksScreen/lib/ui/resources/graphic_resources.qrc index 8fa17a89..dbc1753c 100644 --- a/BlocksScreen/lib/ui/resources/graphic_resources.qrc +++ b/BlocksScreen/lib/ui/resources/graphic_resources.qrc @@ -1,5 +1,6 @@ media/graphics/babystep_graphic.png + media/logoblocks400x300.png diff --git a/BlocksScreen/lib/ui/resources/graphic_resources_rc.py b/BlocksScreen/lib/ui/resources/graphic_resources_rc.py index f4bfbf6f..23ed87f8 100644 --- a/BlocksScreen/lib/ui/resources/graphic_resources_rc.py +++ b/BlocksScreen/lib/ui/resources/graphic_resources_rc.py @@ -9,6 +9,225 @@ from PyQt6 import QtCore qt_resource_data = b"\ +\x00\x00\x0d\x8f\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x01\x90\x00\x00\x01\x2c\x08\x06\x00\x00\x00\xed\xb7\xe5\xc2\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x04\x67\x41\x4d\x41\x00\x00\xb1\x8f\x0b\xfc\x61\x05\x00\x00\x00\ +\x09\x70\x48\x59\x73\x00\x00\x12\x74\x00\x00\x12\x74\x01\xde\x66\ +\x1f\x78\x00\x00\x0d\x24\x49\x44\x41\x54\x78\x5e\xed\xdd\x81\x95\ +\xe3\x34\x1a\x07\x70\x98\x06\x80\x0a\x80\x0a\x80\x0a\x58\x2a\x38\ +\xa8\x00\xa8\x00\xa8\x80\xa5\x02\x8e\x0a\x0e\x2a\x38\xa8\x80\xdd\ +\x0a\x80\x0a\x80\x0a\x58\x1a\x60\xef\xff\x4d\x34\xf7\xf6\xb8\x9d\ +\x89\x94\xd8\x8e\x63\xff\x7e\xef\xe5\x39\x9e\x71\x1c\xdb\x91\xf5\ +\x59\xb2\x24\xbf\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x2c\xe8\xd5\x36\x85\xb3\x3c\x8f\xf6\x76\xab\ +\x3e\x78\xf5\xd5\x57\x9f\xb4\xf7\x40\xdc\xb4\x29\xb0\x52\x7f\xfd\ +\xf5\xd7\xe7\x89\xcf\x4f\x2a\x48\x77\xaa\x65\x3f\x69\x1f\xe7\x04\ +\x39\x7e\xef\xe6\xb8\xff\x76\x7b\x34\x1f\xf6\x2c\xaf\xc7\xed\x63\ +\xbb\xb3\xcb\x12\x48\x7e\xf0\x49\xae\x24\xb3\x9e\x9f\x73\x55\xfa\ +\x2c\x6f\xbf\xcd\xf4\xb7\xc3\x5f\xe7\xd1\xb3\xcd\xd9\x86\x47\xed\ +\xed\xe2\xb2\x7d\x4a\x20\x13\xca\xe1\xfc\x36\x93\x8f\x0f\x73\x67\ +\xfb\x2e\xdb\x3e\x49\x40\xc9\x76\xbd\x9b\xc9\x3f\x0f\x73\xf7\x9b\ +\x32\x2d\xf6\x7e\x67\xc9\xb2\x3f\xdf\xdc\xdc\x7c\xde\x66\x87\x55\ +\xd0\xc8\xb6\xbf\xd9\x66\x4f\xf1\x5e\x3e\xff\x73\x7b\xbf\x79\x7b\ +\x0d\x20\x73\x67\x76\x9f\x26\x11\x55\x06\x30\x99\x9e\x6d\xce\x77\ +\x5e\xec\xf7\x5c\xe0\x98\x5e\xda\xec\x01\x24\x87\xf0\xf5\xbc\xea\ +\xa2\xe4\x9c\x0c\xec\x21\x4f\xb3\xee\xb3\x32\xf6\x6c\x5f\x7d\xfe\ +\xc7\xc3\xdc\xfd\xa6\x4a\x8b\x75\x4c\x32\xf9\xe3\x30\x77\xdc\xa9\ +\xdf\x9b\xef\xa9\x52\xc4\x97\x87\xb9\x49\xec\xa2\xca\x53\x15\xd6\ +\x3c\xfe\x95\x04\x59\x2e\x56\x22\xe0\xba\x54\x62\xc9\xe4\x8f\x64\ +\x3a\x73\x05\x8f\xf2\xfe\x6d\xaa\x7c\xfe\xbc\x4a\xcd\xd7\xa2\x3b\ +\x78\xc4\x47\x6d\x3a\xa4\x0e\x48\x26\x53\x06\x8f\xf2\x63\x56\xbb\ +\xf9\x6a\x44\x01\x64\x5e\x95\x88\x9e\xa7\x58\xdc\x55\xfc\x66\x5f\ +\x92\x34\x1e\x55\xfa\x28\xed\x4f\x4b\x79\xad\x7d\x6d\x79\xab\xfd\ +\x6d\x75\x6a\xe3\xda\xdb\xa3\xaa\xe4\x11\xdf\xb7\xd9\x2e\x59\xfd\ +\xbb\x23\xdf\x71\x82\xbb\x0b\xc9\x2a\x45\x6d\x92\x00\xb2\x80\x24\ +\xec\xcf\xb6\x9c\x88\x18\x97\xf4\xd0\x55\x15\xb4\x80\x5f\x73\x81\ +\xb3\xba\x3a\xfb\x1c\x9f\x91\x52\xd2\x1b\x6d\xda\x2d\xeb\xaf\xc0\ +\xf9\xd3\x61\x6e\x76\x23\xa5\xa8\xab\x22\x80\x2c\x67\xb3\x89\x88\ +\x31\xc9\xbc\xaa\x6e\x7c\x0d\xc1\xe3\x56\x2e\x70\xde\x69\x6f\x57\ +\xa1\x05\xb4\xd7\x0e\x73\x47\xfd\x99\xed\x3f\xa5\x4a\xee\xd7\x36\ +\x5d\x44\xbb\x60\xd8\x1c\x01\x64\x41\x83\x57\x55\x6c\x50\x4b\x03\ +\xef\x1f\xe6\xf8\xbb\x1c\x9f\xc7\x23\x01\x2d\xcb\x0e\x97\xec\x2f\ +\x74\x1e\xae\xe6\x82\x61\x4a\x02\xc8\xb2\xaa\xee\x59\xfb\xfc\x9d\ +\x6a\xbf\x7d\xef\x95\xf5\x5e\x75\xdf\xcc\x4e\xf0\x38\xb5\xa5\xd7\ +\xf0\x6f\x90\xdf\xee\xf7\x4c\xbe\xaa\x57\xde\x7f\x93\xe9\x9f\xf5\ +\xf7\x11\x5b\xbc\x17\xaa\x19\xef\x3d\xee\x4b\x9c\xf9\x68\x15\x45\ +\xab\xfe\xb4\x32\x83\x93\xae\x24\x4f\x49\xf8\xe7\x6c\xf3\x12\x7a\ +\xb6\xaf\xa9\x93\x70\x4d\xea\xf7\xec\xf9\x1d\xcf\x6a\x96\x39\x70\ +\x7c\x5e\xa6\x32\xab\xcf\xf3\xfd\xdd\x4d\xc3\xf3\x75\x9f\xe4\x55\ +\x57\xf3\x5d\xad\xba\x7a\xd3\x4e\xd6\xd9\x75\xef\xa6\x77\x7d\x77\ +\xb2\xde\x45\x9a\xeb\x26\x13\xff\x3e\x1f\xfd\x47\x9b\x3d\xa6\xab\ +\xd9\x73\x55\xb9\x65\xb9\xae\x52\xd3\xa9\xdb\xcd\x8a\x24\xb1\x1e\ +\xd5\x16\xed\x92\xc5\x47\x7a\x09\x57\xab\xac\xa1\xd6\x22\xa5\x7d\ +\xf4\x41\x6d\xd1\x8b\x68\x9b\x70\x54\x5b\x7c\x35\xb2\x49\x8f\x0f\ +\x5b\x76\xd4\xc9\x75\xd8\xf9\x6c\xf5\x56\x1e\x96\x74\x32\x49\xe7\ +\xd4\xac\xea\xdb\xc3\x1a\xef\xd7\x16\x3d\x2a\x8b\xfe\xb7\xe5\xd8\ +\x43\xda\xe2\xdd\xda\xc7\x7a\x9d\xdc\x72\xac\x7d\xbe\x4b\xfb\x48\ +\x97\x2c\x5e\x01\xfb\x21\x9b\xec\xad\xae\x0a\x6b\x02\x75\x95\x32\ +\x72\x65\x91\x45\x7b\xaf\x80\xd8\x86\xa1\x2a\x93\x64\x36\xbf\x57\ +\x7a\xba\xb9\xb9\x99\xa4\x89\x6d\x56\xf5\x49\xad\xaf\xd6\xdb\xfe\ +\xb4\x2a\x95\xbb\xb6\xb7\x3d\xaa\x24\x78\x52\x60\xcd\xd7\x74\x1f\ +\xcf\x3a\x5e\xed\x6d\x97\x2c\x5e\xa5\xc3\x4f\x0f\x73\xff\xa3\x4a\ +\x8f\x6f\xe4\xff\x02\x08\x0f\x1b\x4d\x74\x6c\xdf\x60\xe6\x58\xcb\ +\xff\x32\x55\xe0\xf8\xbb\x5a\x6f\x4b\xa3\xc3\xf5\xf7\x73\xc9\xfe\ +\xd6\x30\x25\x5d\xb2\xec\x17\xd9\xfc\x73\xaa\x11\x7b\x87\x38\x39\ +\xa9\x9a\x35\xdb\xf6\xf7\x2a\xc6\xb7\xf3\xb7\xd7\xf3\xda\x6c\xe3\ +\x19\x01\x64\x62\x49\xa4\xab\xbc\xca\x63\x79\x49\x0b\x23\x55\x43\ +\x77\xa5\x8e\xee\x0c\xf5\x54\x95\xa9\x65\x32\xdc\x77\x62\x6a\xd9\ +\xe7\xca\x70\xbb\xfa\x62\x64\xd9\x0a\xac\x67\xdd\x84\xce\x7e\x7f\ +\xd6\xde\x3e\x28\xcb\x9d\x5c\x5a\xa8\xdf\xf0\x05\xb3\x8e\x8f\xb7\ +\x06\x02\xc8\xc4\x92\x68\x3e\x6c\x6f\xd9\xb1\x96\x39\x76\x9b\xab\ +\xd4\x71\x9f\xa4\xd3\x67\x95\xc3\x55\xc6\xdc\xfe\xb4\xa8\x7c\x6f\ +\x35\x42\xe9\x1d\x2c\xf2\xcf\x25\x02\x2b\xe3\x04\x90\x89\xe5\x9c\ +\xdc\xcd\x48\x9c\x3c\x68\x64\x24\xdd\x8b\x95\x06\x5a\xc6\xfc\xb2\ +\xba\xfb\xb9\xfd\xab\x4d\x8f\xca\x39\x65\x14\x87\x95\x12\x40\x60\ +\x62\x83\xad\xa7\xea\xa6\xf0\x45\xeb\xc8\xf3\xfd\x43\xa5\xa5\x73\ +\x55\xb3\xd7\xf6\xf6\xa8\x6c\x9b\xfb\x8a\x2b\x26\x80\xc0\xc4\x92\ +\xe7\x75\xf5\xbd\x78\xfe\xfc\xf9\x0f\x59\x76\xf3\x43\x7e\xbf\xa8\ +\xaa\xf6\xb2\xcf\xfa\x4c\x6c\x84\x00\x02\x13\x4a\x06\xd9\x7d\x2f\ +\xe3\xe6\xe6\x66\x57\xf7\xcb\x72\x6c\xaa\x51\x41\x6f\xd5\xde\x1c\ +\xd5\x7a\x5d\xad\xcf\xb2\x99\x86\x1c\xea\x24\x80\xc0\x84\x92\xf9\ +\xf4\x96\x28\xbe\x6b\xd3\x5d\xc8\x71\x19\xb9\x8f\x51\x4f\xf5\x9b\ +\x23\x13\xef\x6d\xc5\x55\x43\x0e\xed\xf6\x31\xb5\x23\x04\x10\x98\ +\x50\x32\xbe\xde\xa1\x43\xf6\x36\x26\x5a\xef\x30\x25\xf5\x34\xcf\ +\x59\x1a\xa2\x64\xbd\x23\x41\x61\xea\x07\x4c\x6d\x92\x00\x32\xb1\ +\x5c\xb9\x2c\x7a\x43\x92\xf5\xc8\x6f\xdf\xdb\xd4\xf4\x69\x9b\xee\ +\x42\x8e\x4b\x77\x7f\x98\x64\xf2\xab\x39\x7f\x46\xb6\x7b\xaf\x04\ +\x90\xe9\xf5\xd4\xf1\xee\x2a\x03\xd9\x91\xae\x2a\x92\x64\x92\x27\ +\x8f\xab\x75\x6d\x92\x07\x8f\x54\x45\x2d\xd1\x9c\x79\xa8\x97\x79\ +\x05\x91\xd8\xcd\xef\x35\x4a\x00\x99\x50\x12\x5a\xd7\xc9\xb2\xa7\ +\x0c\x64\x67\x3c\xe7\xe3\x05\x39\x1f\xea\x7e\x50\xcf\x38\x60\xf5\ +\x50\xa8\x32\xfb\xcd\xeb\x7c\xc7\x29\xf7\x36\x6e\x1f\x4d\x1d\x8b\ +\x76\xf6\xbc\x06\x02\xc8\x04\x92\xb0\x6e\x47\xe2\xcc\xdb\x9e\x93\ +\xe5\xed\x36\x65\x9f\xd6\x36\x9c\xfd\x2c\x72\x3a\x54\x46\xdd\x15\ +\x50\x93\xa9\x2f\xda\x51\x30\xdb\x56\xcf\xf3\x38\xc5\xaf\xf9\xac\ +\x16\x5a\x2f\x10\x40\x4e\x94\x84\xf4\x61\x5e\xb7\xc3\xb8\x67\xb6\ +\xb7\x57\xed\x57\x39\x59\x36\x3f\x3e\xce\x1e\x25\x19\xf4\x96\x2a\ +\x37\xf7\x50\xa1\x7b\xf4\xde\x84\x5e\xbc\x35\xda\xcd\xcd\x4d\x0d\ +\xaa\x78\xea\x80\x92\xd5\x42\xab\xb8\xd7\x19\x02\xc8\x3d\x5a\x22\ +\xb9\x57\x16\xf9\x77\x5e\xdd\x55\x16\xf9\x48\x8d\x24\xaa\x69\xe0\ +\x76\x75\x05\x90\x25\xaa\x69\xae\x45\xce\x89\x5f\x72\x3c\x2e\xd2\ +\x1a\xad\x95\x7a\xce\x19\x95\xf8\xe3\xdb\x8c\x60\xe7\xf7\x47\x04\ +\x90\x65\x3c\xcd\x55\xcf\x5e\xae\x3c\xf7\xca\x7d\xad\x41\x97\x1e\ +\x20\xb1\x82\x48\x02\xc0\xb9\x83\x49\xde\xdd\x1f\xd9\xe5\xef\x2f\ +\x80\xcc\x28\x09\xf4\x8e\xcc\x65\xfb\xdc\x40\x1f\x54\xb9\x6e\x7b\ +\x7b\x31\x2d\x88\xbd\x77\x98\x3b\xcb\x5d\x20\xd9\x55\x2d\x83\x00\ +\x32\xa3\x96\xa0\xea\xd1\xa4\xbd\x0f\xb2\x81\x5d\x19\x19\x58\x71\ +\x2e\xb9\xc0\xab\x67\x9a\x4f\x35\xb4\xfd\x97\x87\xb3\x7e\xa8\xe7\ +\xfd\xd5\x12\x40\x16\x90\xb4\xf9\xf5\x54\xcf\xb7\x86\x2d\xc9\xb9\ +\xd1\x35\xb0\xe2\x12\x5a\x69\x64\xaa\x3e\x5a\x7f\xec\x21\x88\x08\ +\x20\x0b\xc9\x89\xf2\xe6\xe1\xc2\x44\x5b\x72\x76\xa1\xbb\x53\x60\ +\xce\x89\xd5\x34\x2c\xc8\x79\xfa\x28\xaf\xa9\x1e\xfb\xfb\xc7\xd6\ +\x6b\x1f\x04\x90\xe5\x69\x4b\xce\xe6\x25\x0f\xbe\x7d\xe2\x61\x9b\ +\x3d\xe6\xb6\x69\x6c\x7b\xbf\x0a\xd9\xf4\x7a\x96\x79\x6d\xff\x59\ +\x25\x92\xac\xe2\xeb\xb5\xed\xdb\x94\x04\x90\x7b\x54\xe2\x39\x26\ +\x8b\xd5\xcd\xb7\x53\x3a\x86\xf5\x74\x38\xe4\x8a\x24\x8f\xb8\xc8\ +\xa3\x61\xaf\x40\xf7\x95\x7c\x8e\xe1\xea\x2e\xac\x72\x9a\xdf\x95\ +\x48\xce\x92\x7d\xdb\x64\xbf\x11\x01\xe4\x0c\x49\x57\x75\xf3\xed\ +\x71\x4b\x60\x55\x64\xef\x3e\x59\xdc\x13\xd9\x1c\xbf\xe7\x4b\xe4\ +\xd4\x18\xb9\x0f\xb0\xda\x0b\xab\x76\x8e\x9f\x33\x8a\x44\xf5\x1b\ +\xb9\x68\xb3\xe5\x39\x08\x20\x13\x49\xfa\xaa\x22\x7b\x77\xe7\xa4\ +\x2c\x5b\xf7\x44\x76\xd1\x52\x63\x0f\xf2\x7b\x7e\xdf\xde\xf2\xff\ +\x46\xee\x87\xac\xb6\xba\x27\xbf\xf1\x6f\x79\x55\x20\x39\xb5\xf7\ +\xfc\x4f\x6d\xba\x19\x02\xc8\xc4\x92\xbe\xba\x83\x48\xce\x95\x8b\ +\x37\x61\x64\x32\x5d\x01\x24\xbf\xf9\xee\xfa\x04\xe5\x9c\xa8\xaa\ +\xa9\x4f\x0f\x73\xc7\xad\xa1\x69\xef\x43\xb2\x3f\x9f\xb4\x40\x32\ +\x7c\xa3\x3d\xbf\xff\xa6\xfa\x89\x08\x20\x33\xe8\x0d\x22\x59\x4e\ +\x29\x64\x23\xf2\x5b\xf6\xd6\xdf\xff\xd8\xa6\xbb\x92\xe3\x53\xf7\ +\x00\x7a\x4b\xe7\xef\xe4\xbc\x58\xfd\x8d\xe7\x76\x9e\x8f\x56\x6b\ +\x6d\xea\x41\x55\x02\xc8\x7c\x7a\x9b\xeb\x1a\xe2\x84\x5d\x68\x19\ +\x6e\xb7\x6b\xb8\xb8\xca\x3e\xdd\x55\x6b\xed\x92\x00\x32\x93\xa4\ +\xa9\xde\x2b\xd2\x9e\x07\x50\x71\x1d\xba\x5a\xe4\x25\x63\xdc\xdb\ +\xe3\x6c\x5f\x34\xf2\xd0\xa8\xde\xc7\xe0\x5e\xdc\x5e\x83\x88\x00\ +\x02\x13\x49\x1e\xd2\x5b\xbf\xdd\x3b\xfc\xff\xe6\xb4\x0b\xab\xee\ +\xfb\x21\x09\xb6\xab\x6b\xda\xfb\x80\x8f\xda\xf4\x41\x5b\xea\x5c\ +\x28\x80\xc0\x05\x24\x63\xdc\x5c\x93\xce\x5e\x09\x22\x23\x7d\x22\ +\xaa\x93\xe1\x55\xf4\xa1\xc8\x7e\xf5\xb6\xc4\xdb\x4c\x43\x0a\x01\ +\x04\xa6\xd5\x3b\xb2\xeb\xe6\x9a\x74\x8e\x48\x66\x3b\x52\xe5\x53\ +\x7d\x28\xae\xa9\x24\xf2\xa0\xec\xfa\x66\x1a\xce\x08\x20\x30\xa1\ +\x64\x0e\xdd\x4d\x50\xf7\x5c\x0a\x69\x46\xee\x87\x18\xbd\x61\x85\ +\x04\x10\xb8\x9c\xbd\x97\x42\x9e\x25\x88\x7e\xd1\x66\x8f\xca\xb2\ +\x93\x37\xed\xcd\x2a\x35\xa3\x3f\x83\x00\x02\xd3\xeb\x1e\x1f\x6d\ +\x8e\x4c\x71\x54\x36\xe1\x62\xd5\x43\xa3\x4f\xea\x9c\xaa\x93\x61\ +\xf6\xf9\xad\xb6\xdf\x97\x68\xe9\xf5\xa4\x4d\xaf\x9e\x00\x02\x13\ +\xcb\x95\xf5\x50\x6f\xe3\x4b\x65\xe0\xf9\xde\x7a\xa4\x6b\x05\xb0\ +\x4b\x57\x0f\x75\xb5\x5e\x2a\x39\xb6\xd5\xc9\xf0\xac\x66\xd0\x6d\ +\x9f\x7f\xcd\xeb\x76\xbf\x33\xbb\x74\xef\xf0\xcd\x8c\x40\x21\x80\ +\xcc\xa4\x25\xd2\x1e\xa7\x8c\xe6\xcb\xca\x25\xa3\x1b\xb9\x49\x5c\ +\x2d\x8d\x16\x0d\x22\x2d\x7d\xae\xa2\x9f\x45\x0e\x55\xb5\x5e\x1a\ +\xb9\x1f\x32\xdc\x0c\xba\x06\x2f\xad\x7d\x2e\xed\x4f\x2f\xaa\xa7\ +\x08\x2e\xf6\x9c\x9e\xb6\xbf\x9b\x20\x80\xcc\x60\xa4\x98\x9d\xc4\ +\xb4\xab\x67\x28\xef\xcc\x07\x6d\xda\xe3\xf6\x99\x18\x31\x6b\x9d\ +\x7c\xd6\xff\xa8\xbe\xa4\xcd\xae\x46\xce\x83\x59\xef\x87\x64\xfd\ +\x6f\xb6\xb7\xf7\xa9\x12\xc9\x59\xb2\x49\x9b\xa9\x9a\xea\x25\x80\ +\x4c\xac\x82\x47\x12\xeb\x6a\x1e\xd3\xc9\xe5\x24\x1d\x3c\x49\xa6\ +\x32\xfa\x9c\x90\xd9\x1e\x85\xda\x32\xdd\xd5\x8e\xc5\x35\x7a\x3f\ +\x24\xbb\x33\x69\xa9\xad\x1d\x9f\x93\xe4\xa3\xd5\xa2\xee\xfd\xc3\ +\xdc\x7e\x08\x20\x13\x4a\x22\x7a\x32\x12\x3c\xb2\xfc\x37\xed\xed\ +\x6e\xd4\x49\xba\x26\xd9\xa4\x59\x07\xb7\x6b\xcf\xd9\x1e\x55\x8f\ +\x42\x9d\xec\xf9\x22\x75\x51\xd3\xf6\xf5\x1a\x74\xf7\x52\x8f\xc9\ +\x3b\x19\xe6\x58\x0d\x57\x2f\x65\x1b\xaa\x63\xe0\x2e\x5b\xd4\xed\ +\x72\xfc\x96\xce\x93\xe9\xa5\xf7\x26\xf2\xd1\x7a\xd4\xe5\x24\x57\ +\x1b\x59\x4f\xf7\xf1\xef\xdc\xe6\x49\xad\x7d\xfb\x16\xf6\x41\x0e\ +\xc7\xc9\x55\x14\x67\x1e\x9f\x3f\xf3\xf1\xc7\x3d\x57\xe8\x59\xae\ +\x5a\x17\x7d\x9f\x6d\x1d\x2a\x05\xf7\xfe\xd6\x59\x77\x65\x96\x47\ +\x4b\x31\xbd\xeb\x7b\x99\xda\x87\x4c\x46\xaa\x94\xde\xc8\xd7\x3d\ +\x58\x1a\x39\x61\x9d\xe5\xbd\xac\xf7\xde\xea\xe8\xac\xb3\x6e\xe6\ +\x0f\xdd\x8f\x39\xe7\xb8\xac\xd1\xa6\x76\xa6\x57\x7e\xf8\x35\x64\ +\x76\x47\x13\xfd\x8b\x2e\xb1\xcd\x23\x89\x7d\x25\xc7\x74\x4e\x67\ +\x05\x90\xb2\xe6\x63\xd4\xfb\x5b\x67\x17\x66\x0f\x20\x25\xdf\x53\ +\xf7\x06\xbb\x4b\x87\x3d\xdf\xb7\x82\xe3\xff\x34\x9b\xb9\xa9\xe7\ +\xc1\xa8\xc2\xba\x90\x24\xa4\x8b\x34\xdd\xe4\x72\x7a\x32\x39\x0e\ +\x72\xa8\x46\x9b\x42\xf7\x04\x87\x91\x96\x5e\x93\xdb\x5a\xf0\x28\ +\x02\xc8\x65\xf4\x8e\x97\xc4\xf6\x8c\xd4\xf1\xef\xdd\xd0\xb1\x3a\ +\xd6\xfa\xb1\x5d\xb4\x9d\xfa\x38\xda\x73\x6d\xf2\x9c\xdf\xe5\x15\ +\xd1\x85\x8b\xb2\x0f\xd6\xab\xde\xe7\x12\xdb\x9c\xed\xec\x4e\x1f\ +\x17\x3e\xa6\x4b\x38\xbb\x0a\xeb\x4e\x0e\x55\xb5\xb2\x5a\xd5\xb3\ +\x2e\x7a\x7f\xeb\x6c\xfb\x22\x55\x58\x77\x46\x5b\x35\xf6\x7c\xef\ +\x25\xd2\xea\x54\xc7\x63\x6d\x94\x40\x16\x92\x34\xfb\x43\x25\xa2\ +\xd8\x4c\x2f\x54\x4e\x93\x34\xf0\xac\x12\x42\xd2\x44\x77\xbf\x87\ +\x19\x55\x60\x5c\x6d\xe6\xd6\x5a\xb1\x4d\x3a\x34\x4c\xdb\xdf\xe1\ +\xe7\x99\x9f\xe8\xd3\x35\x1f\xdf\x73\x09\x20\xcb\xf8\x2a\x27\xc2\ +\x87\xed\x3d\xdc\xaa\x56\x55\x2d\x73\x79\x7a\xf8\xcb\xa2\xbe\xab\ +\xef\x8e\xd5\x77\x7e\xcb\x36\x3e\x4e\x5c\xf8\xbd\xcd\x1e\xd5\x19\ +\x44\x6a\x18\x97\x1f\xda\xec\x5c\xaa\xb6\xe1\x2a\x9e\x65\x72\x2a\ +\x01\x64\x5e\x1f\xd5\x19\x1a\x7a\x9b\x73\xaf\xa4\x8f\xaa\x16\x1a\ +\xe9\xb5\x7e\xae\xca\xd8\xae\xea\xb1\xba\x09\xb6\x43\x43\x8d\xf4\ +\xf4\xa3\x69\x17\x75\x6f\x1f\xe6\x26\x77\x52\x55\xf5\xb5\x11\x40\ +\xa6\x51\x57\x90\xdf\xe5\x8a\xe6\x8b\x8a\x16\x2f\xd8\xcc\x98\x37\ +\xcc\x2b\x69\xa5\x3a\xa1\xde\xca\xec\x2c\xd5\x2b\x6d\xf5\xe5\x2a\ +\x33\xb6\x9c\x5f\xdd\xbd\xfa\xb3\x8f\xc7\x86\x2e\xb9\x95\xe5\x7e\ +\xab\x03\x92\xb7\x53\x95\x02\x6f\xab\xac\x62\x17\x55\xd5\x9b\xad\ +\x9b\x63\x59\x39\xb9\xb7\x5e\xca\xfa\x36\x99\xc2\x64\xbd\xc3\x7b\ +\xe4\x98\x56\xa7\xc0\x0a\x2c\x5d\x99\xe1\x4b\x54\x20\x7a\xb4\x97\ +\xcc\x6c\x0a\x29\xb9\x54\xb5\xe2\x67\x6d\xb6\x4b\x05\xb6\x7c\xe6\ +\x71\x5e\x2e\x18\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x58\xc8\x2b\xaf\xfc\x07\x7b\x91\x3e\x08\xcb\xfc\ +\xfd\x84\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ \x00\x00\x20\x8d\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -543,6 +762,11 @@ \x00\x73\xba\xf1\ \x00\x6d\ \x00\x65\x00\x64\x00\x69\x00\x61\ +\x00\x15\ +\x0b\x9a\x75\x47\ +\x00\x6c\ +\x00\x6f\x00\x67\x00\x6f\x00\x62\x00\x6c\x00\x6f\x00\x63\x00\x6b\x00\x73\x00\x34\x00\x30\x00\x30\x00\x78\x00\x33\x00\x30\x00\x30\ +\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x14\ \x04\xcd\xd8\xa7\ \x00\x62\ @@ -553,9 +777,10 @@ qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x04\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x05\ \x00\x00\x00\x26\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x56\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x93\ " qt_resource_struct_v2 = b"\ @@ -563,12 +788,14 @@ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x04\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x05\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x26\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x9a\x72\xe1\x92\x28\ +\x00\x00\x00\x56\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x93\ +\x00\x00\x01\x9a\x72\xe1\x92\x28\ " qt_version = [int(v) for v in QtCore.qVersion().split('.')] diff --git a/BlocksScreen/lib/utils/blocks_button.py b/BlocksScreen/lib/utils/blocks_button.py index 2134b32d..3ccf4e8b 100644 --- a/BlocksScreen/lib/utils/blocks_button.py +++ b/BlocksScreen/lib/utils/blocks_button.py @@ -1,6 +1,7 @@ import enum import typing +from lib.utils.blocks_pixmap import BlocksPixmap from PyQt6 import QtCore, QtGui, QtWidgets @@ -130,7 +131,7 @@ def setProperty(self, name: str, value: typing.Any): self.text_color = QtGui.QColor(value) self.update() - def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]): + def paintEvent(self, e: QtGui.QPaintEvent | None): """Re-implemented method, paint widget""" painter = QtGui.QPainter(self) painter.setRenderHint(painter.RenderHint.Antialiasing, True) @@ -191,11 +192,7 @@ def _paint_pixmap(self, painter: QtGui.QPainter, bg_color: QtGui.QColor) -> None icon_size, icon_size, ) - _icon_scaled = self.icon_pixmap.scaled( - _icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + _icon_scaled = BlocksPixmap.get(self.icon_pixmap, _icon_rect) scaled_width = _icon_scaled.width() scaled_height = _icon_scaled.height() adjusted_x = (_icon_rect.width() - scaled_width) / 2.0 @@ -207,23 +204,14 @@ def _paint_pixmap(self, painter: QtGui.QPainter, bg_color: QtGui.QColor) -> None scaled_height, ) if not self.isEnabled(): - tinted_icon_pixmap = QtGui.QPixmap(_icon_scaled.size()) - tinted_icon_pixmap.fill(QtCore.Qt.GlobalColor.transparent) - icon_painter = QtGui.QPainter(tinted_icon_pixmap) - icon_painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) - icon_painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform) - icon_painter.drawPixmap(0, 0, _icon_scaled) - icon_painter.setCompositionMode( - QtGui.QPainter.CompositionMode.CompositionMode_SourceAtop + # SourceAtop over the background at alpha 120 dims rather than recolours. + _icon_scaled = BlocksPixmap.tinted( + _icon_scaled, + QtGui.QColor(bg_color.red(), bg_color.green(), bg_color.blue(), 120), + QtGui.QPainter.CompositionMode.CompositionMode_SourceAtop, ) - tint = QtGui.QColor(bg_color.red(), bg_color.green(), bg_color.blue(), 120) - icon_painter.fillRect(tinted_icon_pixmap.rect(), tint) - icon_painter.end() - final_pixmap = tinted_icon_pixmap - else: - final_pixmap = _icon_scaled destination_point = adjusted_icon_rect.toRect().topLeft() - painter.drawPixmap(destination_point, final_pixmap) + painter.drawPixmap(destination_point, _icon_scaled) def _paint_text( self, painter: QtGui.QPainter, rect: QtCore.QRect, text_color: QtGui.QColor diff --git a/BlocksScreen/lib/utils/blocks_label.py b/BlocksScreen/lib/utils/blocks_label.py index cded73c2..84c88ca2 100644 --- a/BlocksScreen/lib/utils/blocks_label.py +++ b/BlocksScreen/lib/utils/blocks_label.py @@ -2,6 +2,8 @@ from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class BlocksLabel(QtWidgets.QLabel): """Custom QLabel with marquee scrolling, glow animation, and icon overlay support.""" @@ -216,11 +218,7 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: self.width() - self.icon_margin, self.height() - self.icon_margin, ) - _icon_scaled = self.icon_pixmap.scaled( - icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + _icon_scaled = BlocksPixmap.get(self.icon_pixmap, icon_rect) scaled_width = _icon_scaled.width() scaled_height = _icon_scaled.height() adjusted_x = (icon_rect.width() - scaled_width) // 2.0 diff --git a/BlocksScreen/lib/utils/blocks_pixmap.py b/BlocksScreen/lib/utils/blocks_pixmap.py index f4e2ba28..db5456db 100644 --- a/BlocksScreen/lib/utils/blocks_pixmap.py +++ b/BlocksScreen/lib/utils/blocks_pixmap.py @@ -8,6 +8,61 @@ # Widgets rescale anyway; 128 clears the tallest button at 64 KiB a surface. ICON_SIZE = QtCore.QSize(128, 128) +_KEEP_ASPECT = QtCore.Qt.AspectRatioMode.KeepAspectRatio +_SMOOTH = QtCore.Qt.TransformationMode.SmoothTransformation +_SOURCE_IN = QtGui.QPainter.CompositionMode.CompositionMode_SourceIn + +_SizeLike = QtCore.QSize | QtCore.QSizeF | QtCore.QRect | QtCore.QRectF + +MiB = 1024 * 1024 + + +def _as_size(size: _SizeLike) -> QtCore.QSize: + """Round any Qt size or rect to a QSize, so a caller passes whatever it already holds.""" + if isinstance(size, (QtCore.QRect, QtCore.QRectF)): + size = size.size() + return size if isinstance(size, QtCore.QSize) else size.toSize() + + +def _cost(pixmap: QtGui.QPixmap) -> int: + """Byte footprint of a surface: an entry count says nothing on a 2 GB board.""" + return pixmap.width() * pixmap.height() * pixmap.depth() // 8 + + +class _PixmapCache: + """Byte-bounded LRU, so one 400x300 thumbnail cannot evict a screenful of icons.""" + + def __init__(self, budget: int) -> None: + """Hold entries newest-last, since a dict already preserves insertion order.""" + self._entries: dict = {} + self._budget = budget + self._bytes = 0 + + def __len__(self) -> int: + """Report the entry count, so a caller can assert the cache emptied.""" + return len(self._entries) + + def get(self, key) -> QtGui.QPixmap | None: + """Return the entry for *key* and move it to newest, which is the LRU touch.""" + pixmap = self._entries.pop(key, None) + if pixmap is not None: + self._entries[key] = pixmap + return pixmap + + def put(self, key, pixmap: QtGui.QPixmap) -> QtGui.QPixmap: + """Store *pixmap*, then drop least-recent entries until back inside the budget.""" + self._entries[key] = pixmap + self._bytes += _cost(pixmap) + # An evicted entry a widget still holds only mints a duplicate, never a fault. + while self._bytes > self._budget and len(self._entries) > 1: + self._bytes -= _cost(self._entries.pop(next(iter(self._entries)))) + return pixmap + + def clear(self) -> None: + """Drop every entry and reset the running total with it.""" + self._entries.clear() + self._bytes = 0 + class Icon(StrEnum): """Every resource key the handwritten panels draw, one member per asset.""" @@ -53,6 +108,7 @@ class Icon(StrEnum): LEFT_ARROW = ":/arrow_icons/media/btn_icons/left_arrow.svg" LOADED_SPOOL = ":/filament_related/media/btn_icons/loaded_spool.svg" LOAD_FILAMENT = ":/filament_related/media/btn_icons/load_filament.svg" + LOGO_BLOCKS = ":/graphics/media/logoblocks400x300.png" MOVE_NOZZLE_AWAY = ":/baby_step/media/btn_icons/move_nozzle_away.svg" MOVE_NOZZLE_CLOSE = ":/baby_step/media/btn_icons/move_nozzle_close.svg" NO = ":/dialog/media/btn_icons/no.svg" @@ -110,10 +166,14 @@ class BlocksPixmap: # Two QIcons over one path do not share their render cache, so hold the QIcon. _icons: ClassVar[dict[str, QtGui.QIcon]] = {} - _pixmaps: ClassVar[dict[tuple[str, int, int], QtGui.QPixmap]] = {} - # An evicted entry a widget still holds only mints a duplicate. - _MAX_PIXMAPS = len(Icon) * 2 + # Rendered from a path: a stable set, so it never shares an eviction budget with + # the derived caches below, which churn on every new thumbnail. + _pixmaps: ClassVar[_PixmapCache] = _PixmapCache(12 * MiB) + + # Derived from a surface, keyed by cacheKey, so thumbnails and tints cache too. + _scaled: ClassVar[_PixmapCache] = _PixmapCache(8 * MiB) + _tints: ClassVar[_PixmapCache] = _PixmapCache(8 * MiB) @classmethod def icon(cls, icon: Icon | str) -> QtGui.QIcon: @@ -126,19 +186,70 @@ def icon(cls, icon: Icon | str) -> QtGui.QIcon: return cached @classmethod - def get(cls, icon: Icon | str, size: QtCore.QSize = ICON_SIZE) -> QtGui.QPixmap: - """Return *icon* rasterized at *size*, cached across calls.""" - key = (str(icon), size.width(), size.height()) + def get( + cls, + source: Icon | str | QtGui.QPixmap, + size: _SizeLike = ICON_SIZE, + aspect: QtCore.Qt.AspectRatioMode = _KEEP_ASPECT, + ) -> QtGui.QPixmap: + """Return *source* at *size*, cached; a path re-renders, a surface can only resample.""" + target = size if isinstance(size, QtCore.QSize) else _as_size(size) + if isinstance(source, QtGui.QPixmap): + return cls._resampled(source, target, aspect) + return cls._rendered(source, target, aspect) + + @classmethod + def _rendered( + cls, path: Icon | str, size: QtCore.QSize, aspect: QtCore.Qt.AspectRatioMode + ) -> QtGui.QPixmap: + """Rasterize a resource path, which an SVG does crisply at any size.""" + # Icon is a StrEnum, so it keys identically to the bare path and needs no str(). + key = (path, size.width(), size.height(), aspect) cached = cls._pixmaps.get(key) if cached is not None: return cached - pixmap = cls.icon(icon).pixmap(size) - cls._pixmaps[key] = pixmap - # A widget resizing mid-drag mints one entry per pixel width; drop the oldest half. - if len(cls._pixmaps) > cls._MAX_PIXMAPS: - for stale in list(cls._pixmaps)[: cls._MAX_PIXMAPS // 2]: - del cls._pixmaps[stale] - return pixmap + pixmap = cls.icon(path).pixmap(size) + # QIcon.pixmap always fits inside, so a distorting mode needs a second pass. + if aspect is not _KEEP_ASPECT and pixmap.size() != size: + pixmap = pixmap.scaled(size, aspect, _SMOOTH) + return cls._pixmaps.put(key, pixmap) + + @classmethod + def _resampled( + cls, + pixmap: QtGui.QPixmap, + size: QtCore.QSize, + aspect: QtCore.Qt.AspectRatioMode, + ) -> QtGui.QPixmap: + """Resample an existing surface, the only option when no resource path is in hand.""" + key = (pixmap.cacheKey(), size.width(), size.height(), aspect) + cached = cls._scaled.get(key) + if cached is not None: + return cached + return cls._scaled.put(key, pixmap.scaled(size, aspect, _SMOOTH)) + + @classmethod + def tinted( + cls, + pixmap: QtGui.QPixmap, + color: QtGui.QColor | str, + mode: QtGui.QPainter.CompositionMode = _SOURCE_IN, + ) -> QtGui.QPixmap: + """Return *pixmap* recoloured to *color* through its own alpha, cached per surface.""" + if not isinstance(color, QtGui.QColor): + color = QtGui.QColor(color) + key = (pixmap.cacheKey(), color.rgba(), mode) + cached = cls._tints.get(key) + if cached is not None: + return cached + result = QtGui.QPixmap(pixmap.size()) + result.fill(QtCore.Qt.GlobalColor.transparent) + painter = QtGui.QPainter(result) + painter.drawPixmap(0, 0, pixmap) + painter.setCompositionMode(mode) + painter.fillRect(result.rect(), color) + painter.end() + return cls._tints.put(key, result) @classmethod def source(cls, icon: Icon | str) -> QtGui.QPixmap: @@ -148,5 +259,5 @@ def source(cls, icon: Icon | str) -> QtGui.QPixmap: @classmethod def clear(cls) -> None: """Drop every cached icon and pixmap, called from on_quit while qApp is alive.""" - cls._icons.clear() - cls._pixmaps.clear() + for cache in (cls._icons, cls._pixmaps, cls._scaled, cls._tints): + cache.clear() diff --git a/BlocksScreen/lib/utils/blocks_progressbar.py b/BlocksScreen/lib/utils/blocks_progressbar.py index 56494700..de604120 100644 --- a/BlocksScreen/lib/utils/blocks_progressbar.py +++ b/BlocksScreen/lib/utils/blocks_progressbar.py @@ -2,6 +2,8 @@ from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class CustomProgressBar(QtWidgets.QProgressBar): """Custom circular progress bar for tracking print jobs @@ -41,11 +43,7 @@ def set_pen_width(self, value) -> None: def _scale_pixmap(self) -> None: self._inner_rect = self._calculate_inner_geometry() - self._pixmap_cached = self._pixmap.scaled( - self._inner_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + self._pixmap_cached = BlocksPixmap.get(self._pixmap, self._inner_rect) def set_inner_pixmap(self, pixmap: QtGui.QPixmap) -> None: """Set the inner icon pixmap on the progress bar diff --git a/BlocksScreen/lib/utils/display_button.py b/BlocksScreen/lib/utils/display_button.py index 1bc7b723..707d9398 100644 --- a/BlocksScreen/lib/utils/display_button.py +++ b/BlocksScreen/lib/utils/display_button.py @@ -2,6 +2,8 @@ from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class DisplayButton(QtWidgets.QPushButton): def __init__(self, parent: typing.Optional["QtWidgets.QWidget"] = None) -> None: @@ -140,12 +142,7 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: _rect.height() - 5, ) - _icon_scaled = self.icon_pixmap.scaled( - int(_icon_rect.width()), - int(_icon_rect.height()), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + _icon_scaled = BlocksPixmap.get(self.icon_pixmap, _icon_rect) scaled_width = _icon_scaled.width() scaled_height = _icon_scaled.height() @@ -279,11 +276,8 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: _icon_size, _icon_size, ) - _first_icon_scaled = self.icon_pixmap.scaled( - int(_icon_size), - int(_icon_size), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, + _first_icon_scaled = BlocksPixmap.get( + self.icon_pixmap, QtCore.QSizeF(_icon_size, _icon_size) ) _first_adjusted_icon_rect = QtCore.QRectF( _first_icon_rect.x() @@ -324,11 +318,9 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: _icon_size, _icon_size, ) - _second_icon_scaled = self.icon_pixmap_secondary.scaled( - int(_icon_size), - int(_icon_size), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, + _second_icon_scaled = BlocksPixmap.get( + self.icon_pixmap_secondary, + QtCore.QSizeF(_icon_size, _icon_size), ) _second_adjusted_icon_rect = QtCore.QRectF( _second_icon_rect.x() diff --git a/BlocksScreen/lib/utils/fonts.py b/BlocksScreen/lib/utils/fonts.py index 2e0867cd..e09e44cc 100644 --- a/BlocksScreen/lib/utils/fonts.py +++ b/BlocksScreen/lib/utils/fonts.py @@ -7,7 +7,6 @@ _logger = logging.getLogger(__name__) -# Must match the topbar .svg font-family; test_resource_keys_unit.py guards the drift. MOMCAKE_FAMILY = "Momcake Pro" _FONT_PATHS = ( diff --git a/BlocksScreen/lib/utils/icon_button.py b/BlocksScreen/lib/utils/icon_button.py index 3880d285..bbd15823 100644 --- a/BlocksScreen/lib/utils/icon_button.py +++ b/BlocksScreen/lib/utils/icon_button.py @@ -1,6 +1,8 @@ import typing from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class IconButton(QtWidgets.QPushButton): def __init__(self, parent: QtWidgets.QWidget = None) -> None: @@ -68,11 +70,7 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: _icon_rect = QtCore.QRectF(0.0, 0.0, (self.width()), (self.height() - y)) if not self.icon_pixmap.isNull(): - _icon_scaled = self.icon_pixmap.scaled( - _icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + _icon_scaled = BlocksPixmap.get(self.icon_pixmap, _icon_rect) scaled_width = _icon_scaled.width() scaled_height = _icon_scaled.height() adjusted_x = (_icon_rect.width() - scaled_width) / 2.0 diff --git a/BlocksScreen/lib/utils/list_button.py b/BlocksScreen/lib/utils/list_button.py index deb01bf1..e7a6f949 100644 --- a/BlocksScreen/lib/utils/list_button.py +++ b/BlocksScreen/lib/utils/list_button.py @@ -1,5 +1,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class ListCustomButton(QtWidgets.QPushButton): def __init__(self, parent=None) -> None: @@ -144,11 +146,7 @@ def paintEvent(self, e: QtGui.QPaintEvent | None) -> None: ellipse_rect.width() - icon_margin, ellipse_rect.height() - icon_margin, ) - icon_scaled = self.icon_pixmap.scaled( - icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + icon_scaled = BlocksPixmap.get(self.icon_pixmap, icon_rect) # Center the icon in the ellipse adjusted_x = icon_rect.x() + (icon_rect.width() - icon_scaled.width()) / 2.0 adjusted_y = ( @@ -166,11 +164,7 @@ def paintEvent(self, e: QtGui.QPaintEvent | None) -> None: # Draw second icon (on the left, if present) if not self.second_icon_pixmap.isNull(): - left_icon_scaled = self.second_icon_pixmap.scaled( - left_icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + left_icon_scaled = BlocksPixmap.get(self.second_icon_pixmap, left_icon_rect) # Center the icon in the rect adjusted_x = ( left_icon_rect.x() diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 470766fb..736eedb2 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -303,41 +303,10 @@ class EntryDelegate(QtWidgets.QStyledItemDelegate): ) def __init__(self) -> None: - """Initialise the delegate with a scaled-pixmap cache and default item height.""" + """Initialise the delegate with a default item height.""" super().__init__() self.prev_index: int = 0 self.height: int = 60 - self._scaled_cache: dict[tuple[int, int, int], QtGui.QPixmap] = {} - - def _get_scaled( - self, - pixmap: QtGui.QPixmap, - size: QtCore.QSize, - ) -> QtGui.QPixmap: - """Return *pixmap* scaled to *size*, using a cache to avoid - re-scaling the same icon every paint frame. - - The cache key is (QPixmap.cacheKey(), width, height) which - correctly invalidates when the source pixmap changes. - """ - key = (pixmap.cacheKey(), size.width(), size.height()) - cached = self._scaled_cache.get(key) - if cached is not None: - return cached - scaled = pixmap.scaled( - size, - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) - self._scaled_cache[key] = scaled - # Prevent unbounded growth — 64 entries covers all wifi - # bar variants × protected/open × left/right icons easily. - if len(self._scaled_cache) > 64: - # Drop oldest half - keys = list(self._scaled_cache) - for k in keys[:32]: - del self._scaled_cache[k] - return scaled def clear(self) -> None: """Clears delegate indexing""" @@ -450,14 +419,9 @@ def paint( ) if item.right_icon: - icon_scaled = item.right_icon.scaled( - ellipse_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) painter.drawPixmap( ellipse_rect.toRect(), - icon_scaled, + BlocksPixmap.get(item.right_icon, ellipse_rect), ) left_margin = 10 @@ -469,32 +433,10 @@ def paint( ) if item.left_icon: - l_icon_scaled = item.left_icon.scaled( - int(left_icon_rect.width()), - int(left_icon_rect.height()), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) - + l_icon_scaled = BlocksPixmap.get(item.left_icon, left_icon_rect) if item.color_left_icon: - tinted = QtGui.QPixmap(l_icon_scaled.size()) - tinted.fill(QtCore.Qt.GlobalColor.transparent) - p2 = QtGui.QPainter(tinted) - p2.drawPixmap(0, 0, l_icon_scaled) - p2.setCompositionMode( - QtGui.QPainter.CompositionMode.CompositionMode_SourceIn - ) - p2.fillRect(tinted.rect(), QtGui.QColor(item.color)) - p2.end() - painter.drawPixmap( - left_icon_rect.toRect(), - tinted, - ) - else: - painter.drawPixmap( - left_icon_rect.toRect(), - l_icon_scaled, - ) + l_icon_scaled = BlocksPixmap.tinted(l_icon_scaled, item.color) + painter.drawPixmap(left_icon_rect.toRect(), l_icon_scaled) text_margin = int( rect.right() - ellipse_size - ellipse_margin - rect.height() * 0.10 diff --git a/BlocksScreen/lib/utils/toggleAnimatedButton.py b/BlocksScreen/lib/utils/toggleAnimatedButton.py index b9555876..40a7495a 100644 --- a/BlocksScreen/lib/utils/toggleAnimatedButton.py +++ b/BlocksScreen/lib/utils/toggleAnimatedButton.py @@ -2,6 +2,8 @@ import typing from PyQt6 import QtCore, QtGui, QtWidgets +from lib.utils.blocks_pixmap import BlocksPixmap + class ToggleAnimatedButton(QtWidgets.QAbstractButton): class State(enum.Enum): @@ -254,11 +256,7 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: self.handle_ellipseRect.width() * 0.90, self.handle_ellipseRect.height() * 0.90, ) - _icon_scaled = self.icon_pixmap.scaled( - _icon_rect.size().toSize(), - QtCore.Qt.AspectRatioMode.KeepAspectRatio, - QtCore.Qt.TransformationMode.SmoothTransformation, - ) + _icon_scaled = BlocksPixmap.get(self.icon_pixmap, _icon_rect) # Calculate the actual QRect for the scaled pixmap (centering it if needed) scaled_width = _icon_scaled.width() scaled_height = _icon_scaled.height() diff --git a/tests/util/test_blocks_pixmap_unit.py b/tests/util/test_blocks_pixmap_unit.py index ba9aa16c..d0515c20 100644 --- a/tests/util/test_blocks_pixmap_unit.py +++ b/tests/util/test_blocks_pixmap_unit.py @@ -5,7 +5,12 @@ import pytest from PyQt6 import QtCore, QtGui -from BlocksScreen.lib.utils.blocks_pixmap import BlocksPixmap, Icon +from BlocksScreen.lib.utils.blocks_pixmap import ( + BlocksPixmap, + Icon, + _cost, + _PixmapCache, +) _RC_PACKAGE = "BlocksScreen.lib.ui.resources" _RC_MODULES = ( @@ -54,18 +59,107 @@ def test_icon_objects_are_shared(): def test_pixmap_cache_is_bounded(): - """A widget resizing on every paint cannot grow the cache without limit.""" - for edge in range(16, 16 + BlocksPixmap._MAX_PIXMAPS * 2): + """A widget resizing on every paint cannot grow the cache past its byte budget.""" + for edge in range(16, 400): BlocksPixmap.get(Icon.BACK, QtCore.QSize(edge, edge)) - assert len(BlocksPixmap._pixmaps) <= BlocksPixmap._MAX_PIXMAPS + assert BlocksPixmap._pixmaps._bytes <= BlocksPixmap._pixmaps._budget -def test_clear_empties_both_caches(): +def test_clear_empties_every_cache(): """clear() releases every QPixmap while qApp is still alive.""" - BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + source = BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + BlocksPixmap.tinted(source, QtGui.QColor("white")) BlocksPixmap.clear() assert not BlocksPixmap._icons assert not BlocksPixmap._pixmaps + assert not BlocksPixmap._scaled + assert not BlocksPixmap._tints + + +def test_get_resamples_a_surface_once(): + """A repeat get() on a surface hands back the cache rather than rescaling per paint.""" + source = BlocksPixmap.get(Icon.BACK) + size = QtCore.QSize(40, 40) + assert ( + BlocksPixmap.get(source, size).cacheKey() + == BlocksPixmap.get(source, size).cacheKey() + ) + + +def test_get_keys_on_the_aspect_mode(): + """Distort-to-fill and fit-inside must not collide on one key.""" + source = BlocksPixmap.get(Icon.BACK) + size = QtCore.QSize(80, 20) + keep = BlocksPixmap.get(source, size) + ignore = BlocksPixmap.get(source, size, QtCore.Qt.AspectRatioMode.IgnoreAspectRatio) + assert keep.size() != ignore.size() + + +def test_derived_cache_is_bounded(): + """One 400x300 thumbnail costs 30 icon rescales, so the budget counts bytes not entries.""" + source = BlocksPixmap.get(Icon.BACK) + for edge in range(16, 400): + BlocksPixmap.get(source, QtCore.QSize(edge, edge)) + assert BlocksPixmap._scaled._bytes <= BlocksPixmap._scaled._budget + + +def test_eviction_drops_the_least_recently_used(): + """FIFO would drop the startup icons first, which are the hottest entries of all.""" + cache = _PixmapCache(2 * _cost(BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)))) + for key in ("a", "b"): + cache.put(key, BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32))) + cache.get("a") + cache.put("c", BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32))) + assert cache.get("a") is not None + assert cache.get("b") is None + + +@pytest.mark.parametrize( + "size", + [ + QtCore.QSize(40, 40), + QtCore.QSizeF(40.4, 40.4), + QtCore.QRect(0, 0, 40, 40), + QtCore.QRectF(0.0, 0.0, 40.4, 40.4), + ], + ids=["QSize", "QSizeF", "QRect", "QRectF"], +) +def test_get_accepts_whatever_the_caller_holds(size): + """Every Qt size or rect normalises to one target, so no call site converts by hand.""" + reference = BlocksPixmap.get(Icon.BACK, QtCore.QSize(40, 40)) + assert BlocksPixmap.get(Icon.BACK, size).cacheKey() == reference.cacheKey() + + +def test_tinted_recolours_through_the_alpha(): + """A tint keeps the glyph shape, so a painted pixel takes the colour and a clear one stays clear.""" + source = BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + image = BlocksPixmap.tinted(source, QtGui.QColor("red")).toImage() + painted = [ + image.pixelColor(x, y) + for x in range(image.width()) + for y in range(image.height()) + if image.pixelColor(x, y).alpha() > 0 + ] + assert painted, "the source glyph painted nothing" + assert all(c.red() > 250 and not c.green() and not c.blue() for c in painted) + + +def test_tinted_returns_the_same_surface_twice(): + """A repeat tint hands back the cache rather than allocating a pixmap per paint.""" + source = BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + assert ( + BlocksPixmap.tinted(source, "white").cacheKey() + == BlocksPixmap.tinted(source, "white").cacheKey() + ) + + +def test_tinted_keys_on_the_colour(): + """Two colours over one surface must not collide on one key.""" + source = BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + assert ( + BlocksPixmap.tinted(source, "white").cacheKey() + != BlocksPixmap.tinted(source, "red").cacheKey() + ) def test_get_costs_far_less_than_source():