From 71d697e70362e8d06d9a4c9df1569c8b6c50e7e7 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 1 Sep 2026 12:45:36 +0100 Subject: [PATCH] feat(pixmap): add BlocksPixmap registry and Icon key enum --- BlocksScreen/BlocksScreen.py | 7 +- BlocksScreen/lib/utils/blocks_pixmap.py | 159 ++++++++++++++++++++++++ tests/util/test_blocks_pixmap_unit.py | 95 ++++++++++++++ 3 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 BlocksScreen/lib/utils/blocks_pixmap.py create mode 100644 tests/util/test_blocks_pixmap_unit.py diff --git a/BlocksScreen/BlocksScreen.py b/BlocksScreen/BlocksScreen.py index ec8e5dd0..8a1d4a2f 100644 --- a/BlocksScreen/BlocksScreen.py +++ b/BlocksScreen/BlocksScreen.py @@ -11,9 +11,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from configfile import get_configparser -from lib.panels.mainWindow import MainWindow # noqa: E402 +from lib.panels.mainWindow import MainWindow +from lib.utils.blocks_pixmap import BlocksPixmap from logger import CrashHandler, LogManager, install_crash_handler, setup_logging -from PyQt6 import QtCore, QtGui, QtWidgets # noqa: E402 +from PyQt6 import QtCore, QtGui, QtWidgets from tools.configuration_manager import ConfigManager install_crash_handler() @@ -167,6 +168,8 @@ def _handler(__signum: int, __frame: typing.Any) -> None: def on_quit() -> None: logging.info("Final exit cleanup") + # aboutToQuit still has a live qApp, which QPixmap destruction requires. + BlocksPixmap.clear() LogManager.shutdown() diff --git a/BlocksScreen/lib/utils/blocks_pixmap.py b/BlocksScreen/lib/utils/blocks_pixmap.py new file mode 100644 index 00000000..eace6add --- /dev/null +++ b/BlocksScreen/lib/utils/blocks_pixmap.py @@ -0,0 +1,159 @@ +"""One load-once registry for every Qt resource pixmap the panel draws. + +Loading an icon by path yields its intrinsic buffer, which for most of the +btn_icons is a 600x600 RGBA surface costing 1.37 MiB even when it is painted at +32px. Going through QIcon instead makes QSvgIconEngine rasterize at the +requested size: measured on the RF50, the whole set costs 16.1 MiB at 64px +against 273.0 MiB intrinsic, and loads in 278 ms against 848 ms. + +Every path lives in the Icon enum so a typo is a NameError rather than Qt's +silent null pixmap, and tests/util/test_blocks_pixmap_unit.py proves each member +still resolves. +""" + +from enum import StrEnum +from typing import ClassVar + +from PyQt6 import QtCore, QtGui + + +class Icon(StrEnum): + """Every resource key the handwritten panels draw, one member per asset.""" + + ABS_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/abs_filament_topbar.svg" + ADD_FILAMENT = ":/filament_related/media/btn_icons/add filament.svg" + ADD_SPOOL = ":/filament_related/media/btn_icons/add spool.svg" + ARROW_DOWN = ":/arrow_icons/media/btn_icons/arrow_down.svg" + ARROW_LEFT = ":/arrow_icons/media/btn_icons/arrow_left.svg" + ARROW_RIGHT = ":/arrow_icons/media/btn_icons/arrow_right.svg" + BABYSTEP_GRAPHIC = ":/graphics/media/graphics/babystep_graphic.png" + BABY_STEP_ICON = ":/z_levelling/media/btn_icons/baby_step_icon.svg" + BACK = ":/ui/media/btn_icons/back.svg" + BACKGROUND_1ST = ":/background/media/1st_background.png" + BACK_FOLDER = ":/ui/media/btn_icons/back_folder.svg" + BASE_DADOS_SPOOL_1 = ":/filament_related/media/btn_icons/base dados spool 1.svg" + BLOWER = ":/fan_related/media/btn_icons/blower.svg" + BLTOUCH = ":/z_levelling/media/btn_icons/bltouch.svg" + CHANGE_FILAMENT = ":/filament_related/media/btn_icons/change_filament.svg" + CHECK_GATE_1 = ":/filament_related/media/btn_icons/check gate 1.svg" + EDDY_MECH = ":/z_levelling/media/btn_icons/eddy_mech.svg" + EJECT = ":/filament_related/media/btn_icons/eject.svg" + ERROR = ":/ui/media/btn_icons/error.svg" + ETHERNET_CONNECTED = ":/network/media/btn_icons/network/ethernet_connected.svg" + FAN = ":/fan_related/media/btn_icons/fan.svg" + FAN_CAGE = ":/fan_related/media/btn_icons/fan_cage.svg" + FILAMENT_SENSOR = ":/filament_related/media/btn_icons/filament_sensor.svg" + FILAMENT_SENSOR_OFF = ":/filament_related/media/btn_icons/filament_sensor_off.svg" + FILAMENT_SENSOR_TURN_ON = ( + ":/filament_related/media/btn_icons/filament_sensor_turn_on.svg" + ) + FILE_ICON = ":/files/media/btn_icons/file_icon.svg" + FOLDERICON = ":/ui/media/btn_icons/folderIcon.svg" + GARBAGE_ICON = ":/ui/media/btn_icons/garbage-icon.svg" + HALF_SPOLL = ":/filament_related/media/btn_icons/half_spoll.svg" + HOTSPOT = ":/network/media/btn_icons/hotspot.svg" + INDUCTIVE = ":/z_levelling/media/btn_icons/inductive.svg" + INFO = ":/ui/media/btn_icons/info.svg" + INPUT_SHAPER_AUTO = ":/input_shaper/media/btn_icons/input_shaper_auto.svg" + INPUT_SHAPER_MANUAL = ":/input_shaper/media/btn_icons/input_shaper_manual.svg" + LAYERS = ":/ui/media/btn_icons/layers.svg" + LEDS = ":/ui/media/btn_icons/LEDs.svg" + 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" + 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" + NOTIFICATION = ":/ui/media/btn_icons/notification.svg" + NYLON_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/nylon_filament_topbar.svg" + PAUSE = ":/ui/media/btn_icons/pause.svg" + PC_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/pc_filament_topbar.svg" + PETG_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/petg_filament_topbar.svg" + PLAY = ":/ui/media/btn_icons/play.svg" + PLA_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/pla_filament_topbar.svg" + PP_FILAMENT_TOPBAR = ":/top_bar_icons/media/topbar/pp_filament_topbar.svg" + PRINT = ":/ui/media/btn_icons/print.svg" + PRINTER_SETTINGS = ":/ui/media/btn_icons/printer_settings.svg" + REBOOT = ":/system/media/btn_icons/reboot.svg" + REFRESH = ":/ui/media/btn_icons/refresh.svg" + RESTART_KLIPPER = ":/system/media/btn_icons/restart_klipper.svg" + RIGHT_ARROW = ":/arrow_icons/media/btn_icons/right_arrow.svg" + SAVE = ":/ui/media/btn_icons/save.svg" + SEE = ":/ui/media/btn_icons/see.svg" + SPEED = ":/motion/media/btn_icons/speed.svg" + STATIC_IP = ":/network/media/btn_icons/network/static_ip.svg" + STOP = ":/ui/media/btn_icons/stop.svg" + SWITCH_ZOOM = ":/extruder_related/media/btn_icons/switch_zoom.svg" + TEMPERATURE = ":/temperature_related/media/btn_icons/temperature.svg" + TEMPERATURE_PLATE = ":/temperature_related/media/btn_icons/temperature_plate.svg" + TIME = ":/ui/media/btn_icons/time.svg" + TROUBLESHOOT = ":/ui/media/btn_icons/troubleshoot.svg" + TUNE = ":/ui/media/btn_icons/tune.svg" + UNLOAD_FILAMENT = ":/filament_related/media/btn_icons/unload_filament.svg" + UNSEE = ":/ui/media/btn_icons/unsee.svg" + UPDATE_SOFTWARE_ICON = ":/system/media/btn_icons/update-software-icon.svg" + USB_ICON = ":/ui/media/btn_icons/usb_icon.svg" + WIFI_0BAR = ":/network/media/btn_icons/network/0bar_wifi.svg" + WIFI_0BAR_PROTECTED = ":/network/media/btn_icons/network/0bar_wifi_protected.svg" + WIFI_1BAR = ":/network/media/btn_icons/network/1bar_wifi.svg" + WIFI_1BAR_PROTECTED = ":/network/media/btn_icons/network/1bar_wifi_protected.svg" + WIFI_2BAR = ":/network/media/btn_icons/network/2bar_wifi.svg" + WIFI_2BAR_PROTECTED = ":/network/media/btn_icons/network/2bar_wifi_protected.svg" + WIFI_3BAR = ":/network/media/btn_icons/network/3bar_wifi.svg" + WIFI_3BAR_PROTECTED = ":/network/media/btn_icons/network/3bar_wifi_protected.svg" + WIFI_4BAR = ":/network/media/btn_icons/network/4bar_wifi.svg" + WIFI_4BAR_PROTECTED = ":/network/media/btn_icons/network/4bar_wifi_protected.svg" + WIFI_CONFIG = ":/network/media/btn_icons/wifi_config.svg" + YES = ":/dialog/media/btn_icons/yes.svg" + + @classmethod + def wifi(cls, bars: int, protected: bool = False) -> "Icon": + """Return the signal-strength icon for *bars*, clamped to the 0..4 assets.""" + bars = min(max(bars, 0), 4) + return cls[f"WIFI_{bars}BAR{'_PROTECTED' if protected else ''}"] + + +class BlocksPixmap: + """Process-wide cache of QIcons and the pixmaps rendered from them.""" + + # 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]] = {} + + _MAX_PIXMAPS = 64 + + @classmethod + def icon(cls, icon: Icon | str) -> QtGui.QIcon: + """Return the shared QIcon for *icon*, built from the path so SVGs stay scalable.""" + key = str(icon) + cached = cls._icons.get(key) + if cached is None: + cached = QtGui.QIcon(key) + cls._icons[key] = cached + return cached + + @classmethod + def get(cls, icon: Icon | str, size: QtCore.QSize) -> QtGui.QPixmap: + """Return *icon* rasterized at *size*, cached across calls.""" + key = (str(icon), size.width(), size.height()) + 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 + + @classmethod + def source(cls, icon: Icon | str) -> QtGui.QPixmap: + """Return the intrinsic-size pixmap; only for full-bleed art, never a btn_icon.""" + return QtGui.QPixmap(str(icon)) + + @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() diff --git a/tests/util/test_blocks_pixmap_unit.py b/tests/util/test_blocks_pixmap_unit.py new file mode 100644 index 00000000..40d8c5eb --- /dev/null +++ b/tests/util/test_blocks_pixmap_unit.py @@ -0,0 +1,95 @@ +"""Unit tests for the BlocksPixmap registry and the Icon key enum. + +Qt resolves an unknown ':/' key to a null QPixmap with no exception and no log +line, so a typo in Icon would ship as a blank rectangle on the panel. These +tests turn that into a red test, and pin the size win the registry exists for. +""" + +import importlib + +import pytest +from PyQt6 import QtCore, QtGui + +from BlocksScreen.lib.utils.blocks_pixmap import BlocksPixmap, Icon + +_RC_PACKAGE = "BlocksScreen.lib.ui.resources" +_RC_MODULES = ( + "background_resources_rc", + "font_rc", + "graphic_resources_rc", + "icon_resources_rc", + "main_menu_resources_rc", + "top_bar_resources_rc", +) + + +@pytest.fixture(autouse=True) +def _resources(qapp): + """Register the compiled blobs, then leave the caches empty for the next test.""" + for module in _RC_MODULES: + importlib.import_module(f"{_RC_PACKAGE}.{module}") + BlocksPixmap.clear() + yield + BlocksPixmap.clear() + + +def _bytes(pixmap: QtGui.QPixmap) -> int: + return pixmap.width() * pixmap.height() * pixmap.depth() // 8 + + +@pytest.mark.parametrize("icon", list(Icon), ids=lambda i: i.name) +def test_every_icon_member_resolves(icon): + """Each Icon value names a real resource, so nothing paints blank.""" + pixmap = BlocksPixmap.get(icon, QtCore.QSize(64, 64)) + assert not pixmap.isNull(), f"{icon.name} -> {icon.value} is not a declared key" + + +def test_get_returns_the_same_pixmap_twice(): + """A repeat get() hands back the cached surface rather than re-rendering.""" + size = QtCore.QSize(48, 48) + first = BlocksPixmap.get(Icon.BACK, size) + second = BlocksPixmap.get(Icon.BACK, size) + assert first.cacheKey() == second.cacheKey() + + +def test_icon_objects_are_shared(): + """Two QIcons over one path do not share a render cache, so the QIcon is held.""" + assert BlocksPixmap.icon(Icon.BACK) is BlocksPixmap.icon(Icon.BACK) + + +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): + BlocksPixmap.get(Icon.BACK, QtCore.QSize(edge, edge)) + assert len(BlocksPixmap._pixmaps) <= BlocksPixmap._MAX_PIXMAPS + + +def test_clear_empties_both_caches(): + """clear() releases every QPixmap while qApp is still alive.""" + BlocksPixmap.get(Icon.BACK, QtCore.QSize(32, 32)) + BlocksPixmap.clear() + assert not BlocksPixmap._icons + assert not BlocksPixmap._pixmaps + + +def test_get_costs_far_less_than_source(): + """The whole point: rendering at 64px must beat the intrinsic buffers 5x over.""" + scaled = sum(_bytes(BlocksPixmap.get(i, QtCore.QSize(64, 64))) for i in Icon) + BlocksPixmap.clear() + intrinsic = sum(_bytes(BlocksPixmap.source(i)) for i in Icon) + assert scaled * 5 < intrinsic, f"{scaled} B scaled vs {intrinsic} B intrinsic" + + +@pytest.mark.parametrize( + ("bars", "protected", "expected"), + [ + (0, False, Icon.WIFI_0BAR), + (3, True, Icon.WIFI_3BAR_PROTECTED), + (4, False, Icon.WIFI_4BAR), + (-1, False, Icon.WIFI_0BAR), + (9, True, Icon.WIFI_4BAR_PROTECTED), + ], +) +def test_wifi_clamps_to_the_declared_assets(bars, protected, expected): + """Only 0..4 bar assets exist, so out-of-range signal must clamp, not raise.""" + assert Icon.wifi(bars, protected) is expected