Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions BlocksScreen/BlocksScreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()


Expand Down
159 changes: 159 additions & 0 deletions BlocksScreen/lib/utils/blocks_pixmap.py
Original file line number Diff line number Diff line change
@@ -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()
95 changes: 95 additions & 0 deletions tests/util/test_blocks_pixmap_unit.py
Original file line number Diff line number Diff line change
@@ -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
Loading