From 63babbfd05c38276740fe3579d024af09836056d Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 10:18:25 +0100 Subject: [PATCH 1/3] feat(ui): add unread-notification red dot to header bell and connection page --- BlocksScreen/lib/panels/mainWindow.py | 6 ++ .../lib/panels/widgets/notificationPage.py | 6 ++ BlocksScreen/lib/utils/icon_button.py | 21 +++++ tests/util/test_notification_page_unit.py | 82 +++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 tests/util/test_notification_page_unit.py diff --git a/BlocksScreen/lib/panels/mainWindow.py b/BlocksScreen/lib/panels/mainWindow.py index 035f27f8..f3f6bf4b 100644 --- a/BlocksScreen/lib/panels/mainWindow.py +++ b/BlocksScreen/lib/panels/mainWindow.py @@ -208,6 +208,12 @@ def __init__(self): self.utilitiesPanel.update_available.connect(self.on_update_available) self.ui.notification_btn.clicked.connect(self.notiPage.show_notification_panel) + self.notiPage.has_new_notification.connect( + self.ui.notification_btn.setShowNotification + ) + self.notiPage.has_new_notification.connect( + self.conn_window.notification_button.setShowNotification + ) self.ui.extruder_temp_display.clicked.connect( lambda: self.global_change_page( self.ui.main_content_widget.indexOf(self.ui.controlTab), diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index d21de8b9..261c9d2f 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -18,6 +18,9 @@ class NotificationPage(QtWidgets.QWidget): on_update_message: ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( dict, name="on-update-message" ) + has_new_notification: ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal( + bool, name="has-new-notification" + ) def __init__(self, parent=None) -> None: super().__init__(parent) @@ -56,6 +59,7 @@ def show_notification_panel( self.update() self.show() self.raise_() + self.has_new_notification.emit(False) def delete_selected_item(self) -> None: """Deletes currently selected item from the list view""" @@ -71,6 +75,7 @@ def reset_view_model(self) -> None: """ self.model.clear() self.entry_delegate.clear() + self.has_new_notification.emit(False) def build_model_list(self) -> None: """Builds the model list (`self.model`) containing updatable clients""" @@ -143,6 +148,7 @@ def new_notication( self.popup.new_message(message_type=msg_type, message=message, timeout=3000) self.build_model_list() + self.has_new_notification.emit(True) def _add_notif_entry( self, diff --git a/BlocksScreen/lib/utils/icon_button.py b/BlocksScreen/lib/utils/icon_button.py index 3880d285..ebcff869 100644 --- a/BlocksScreen/lib/utils/icon_button.py +++ b/BlocksScreen/lib/utils/icon_button.py @@ -1,6 +1,9 @@ import typing + from PyQt6 import QtCore, QtGui, QtWidgets +NOTIFICATION_DOT_COLOR = QtGui.QColor(226, 31, 31) + class IconButton(QtWidgets.QPushButton): def __init__(self, parent: QtWidgets.QWidget = None) -> None: @@ -12,9 +15,16 @@ def __init__(self, parent: QtWidgets.QWidget = None) -> None: self._text: str = "" self._name: str = "" self.text_color: QtGui.QColor = QtGui.QColor(255, 255, 255) + self._show_notification: bool = False self.setAttribute(QtCore.Qt.WidgetAttribute.WA_AcceptTouchEvents, True) self.pressed_bg_color = QtGui.QColor(223, 223, 223, 70) # Set to solid white + def setShowNotification(self, show: bool) -> None: + """Set notification dot on button""" + if self._show_notification != show: + self._show_notification = show + self.update() + @property def name(self): """Widget name""" @@ -124,8 +134,19 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: str(self.text()), ) + if self._show_notification: + self._paint_notification(painter) + painter.end() + def _paint_notification(self, painter: QtGui.QPainter) -> None: + """Draw the unread-notification dot in the top-right corner""" + dot_diameter = min(14, self.height() * 0.35) + dot_x = self.width() - dot_diameter + painter.setBrush(NOTIFICATION_DOT_COLOR) + painter.setPen(QtCore.Qt.PenStyle.NoPen) + painter.drawEllipse(QtCore.QRectF(dot_x, 0, dot_diameter, dot_diameter)) + def setProperty(self, name: str, value: typing.Any) -> bool: """Re-implemented method, set widget properties""" if name == "icon_pixmap": diff --git a/tests/util/test_notification_page_unit.py b/tests/util/test_notification_page_unit.py new file mode 100644 index 00000000..f9bbec36 --- /dev/null +++ b/tests/util/test_notification_page_unit.py @@ -0,0 +1,82 @@ +"""Unit tests for NotificationPage's unread-notification dot signal.""" + +import importlib.machinery +import importlib.util +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from PyQt6 import QtWidgets + +# tests/panels/conftest.py and tests/network/conftest.py stub sys.modules +# entries under "lib.*" with fakes (no teardown) when collected in the same +# xdist worker; evict notificationPage's whole dependency closure first so +# we load the real BlocksScreen/lib modules instead of another file's stubs. +for _stale_key in ( + "lib", + "lib.panels", + "lib.panels.widgets", + "lib.panels.widgets.notificationPage", + "lib.panels.widgets.popupDialogWidget", + "lib.utils", + "lib.utils.blocks_button", + "lib.utils.blocks_frame", + "lib.utils.icon_button", + "lib.utils.list_model", +): + sys.modules.pop(_stale_key, None) + +_bs_lib_dir = Path(__file__).resolve().parent.parent.parent / "BlocksScreen" / "lib" +_lib_spec = importlib.machinery.ModuleSpec("lib", loader=None, is_package=True) +_lib_spec.submodule_search_locations = [str(_bs_lib_dir)] +sys.modules["lib"] = importlib.util.module_from_spec(_lib_spec) + +from lib.panels.widgets.notificationPage import NotificationPage # noqa: E402 + +_notification_page_module = sys.modules[NotificationPage.__module__] + + +def _mock_setup(self) -> None: + self.update_buttons_list_widget = QtWidgets.QListView() + for attr in ( + "update_back_btn", + "delete_btn", + "delete_all_btn", + "header_title", + "type_label", + "time_label", + ): + setattr(self, attr, MagicMock()) + + +@pytest.fixture +def page(qtbot): + with ( + patch.object(_notification_page_module, "Popup", MagicMock()), + patch.object(NotificationPage, "_setupUI", _mock_setup), + ): + pg = NotificationPage() + qtbot.addWidget(pg) + return pg + + +def test_new_notification_sets_dot(page, qtbot): + with qtbot.waitSignal(page.has_new_notification, timeout=200) as blocker: + page.new_notication("test", "hello", 1, False) + assert blocker.args == [True] + + +def test_show_notification_panel_clears_dot(page, qtbot): + # show_notification_panel() no-ops without a parent; stub one out. + page.parent = MagicMock(return_value=MagicMock()) + with qtbot.waitSignal(page.has_new_notification, timeout=200) as blocker: + page.show_notification_panel() + assert blocker.args == [False] + + +def test_reset_view_model_clears_dot(page, qtbot): + page.new_notication("test", "hello", 1, False) + with qtbot.waitSignal(page.has_new_notification, timeout=200) as blocker: + page.reset_view_model() + assert blocker.args == [False] From 0d6f365e9af62bd2d1f7902d9de918eefb867ee5 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 10:27:22 +0100 Subject: [PATCH 2/3] fix(ui): shrink unread-notification dot --- BlocksScreen/lib/utils/icon_button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BlocksScreen/lib/utils/icon_button.py b/BlocksScreen/lib/utils/icon_button.py index ebcff869..52cfb79f 100644 --- a/BlocksScreen/lib/utils/icon_button.py +++ b/BlocksScreen/lib/utils/icon_button.py @@ -141,7 +141,7 @@ def paintEvent(self, a0: QtGui.QPaintEvent) -> None: def _paint_notification(self, painter: QtGui.QPainter) -> None: """Draw the unread-notification dot in the top-right corner""" - dot_diameter = min(14, self.height() * 0.35) + dot_diameter = min(10, self.height() * 0.25) dot_x = self.width() - dot_diameter painter.setBrush(NOTIFICATION_DOT_COLOR) painter.setPen(QtCore.Qt.PenStyle.NoPen) From 0b77dc8d6077c9c52783361f68d8c25c86a43787 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 11:20:26 +0100 Subject: [PATCH 3/3] fix(ui): don't set unread-notification dot while the notification panel is already open --- BlocksScreen/lib/panels/widgets/notificationPage.py | 2 +- tests/util/test_notification_page_unit.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index 261c9d2f..99a4d75e 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -148,7 +148,7 @@ def new_notication( self.popup.new_message(message_type=msg_type, message=message, timeout=3000) self.build_model_list() - self.has_new_notification.emit(True) + self.has_new_notification.emit(not self.isVisible()) def _add_notif_entry( self, diff --git a/tests/util/test_notification_page_unit.py b/tests/util/test_notification_page_unit.py index f9bbec36..e0a7553c 100644 --- a/tests/util/test_notification_page_unit.py +++ b/tests/util/test_notification_page_unit.py @@ -75,6 +75,13 @@ def test_show_notification_panel_clears_dot(page, qtbot): assert blocker.args == [False] +def test_new_notification_while_open_does_not_set_dot(page, qtbot): + page.show() + with qtbot.waitSignal(page.has_new_notification, timeout=200) as blocker: + page.new_notication("test", "hello", 1, False) + assert blocker.args == [False] + + def test_reset_view_model_clears_dot(page, qtbot): page.new_notication("test", "hello", 1, False) with qtbot.waitSignal(page.has_new_notification, timeout=200) as blocker: