From 63babbfd05c38276740fe3579d024af09836056d Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 10:18:25 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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: From f30ad6a2c164fa5c0dba10c3126e98b2f8ea82a6 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 11:59:23 +0100 Subject: [PATCH 04/11] refactor(notification): clean up NotificationPage, fix dedupe, show newest first, add icon-text spacing --- .../lib/panels/widgets/notificationPage.py | 155 +++++++++--------- BlocksScreen/lib/utils/list_model.py | 61 +++---- tests/util/test_notification_page_unit.py | 4 +- 3 files changed, 107 insertions(+), 113 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index 99a4d75e..bfb29ae3 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -1,4 +1,3 @@ -from collections import deque from typing import ClassVar from lib.panels.widgets.popupDialogWidget import Popup @@ -10,14 +9,8 @@ class NotificationPage(QtWidgets.QWidget): - """Update GUI Page, - retrieves from moonraker available clients and adds functionality - for updating or recovering them - """ + """Notification panel, lists moonraker/UI notifications and lets the user clear them""" - 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" ) @@ -28,18 +21,18 @@ def __init__(self, parent=None) -> None: self._ICON_WARN = QtGui.QPixmap(":/ui/media/btn_icons/troubleshoot.svg") self._ICON_ERROR = QtGui.QPixmap(":/ui/media/btn_icons/error.svg") self._setupUI() - self.cli_tracking: deque = deque() self.selected_item: ListItem | None = None self.popup = Popup(self) self.model = EntryListModel() - self.model.setParent(self.update_buttons_list_widget) + self.model.setParent(self.notification_list_view) self.entry_delegate = EntryDelegate() - self.update_buttons_list_widget.setModel(self.model) - self.update_buttons_list_widget.setItemDelegate(self.entry_delegate) + self.notification_list_view.setModel(self.model) + self.notification_list_view.setItemDelegate(self.entry_delegate) self.entry_delegate.item_selected.connect(self.on_item_clicked) + self.model.rowsInserted.connect(self._on_rows_inserted) - self.update_back_btn.clicked.connect(self.hide) + self.back_btn.clicked.connect(self.hide) self.delete_btn.clicked.connect(self.delete_selected_item) self.delete_all_btn.clicked.connect(self.reset_view_model) @@ -77,23 +70,34 @@ def reset_view_model(self) -> None: 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""" - self.update_buttons_list_widget.blockSignals(True) + def _on_rows_inserted( + self, _parent: QtCore.QModelIndex, first: int, _last: int + ) -> None: + """Keep the delegate's prev_index valid when a notification is prepended above it.""" + if first <= self.entry_delegate.prev_index: + self.entry_delegate.prev_index += 1 + + def _ingest_notification(self, message: str, priority: int) -> None: + """Adds *message* to the model, collapsing a repeat of the last entry (moonraker echo spam).""" + match priority: + case 1: + color, icon = "#1A8FBF", self._ICON_INFO + case 2: + color, icon = "#E7E147", self._ICON_WARN + case 3: + color, icon = "#CA4949", self._ICON_ERROR + case _: + color, icon = "#a4a4a4", self._ICON_INFO + + if self.model.refresh_last_if_duplicate(message, color): + return + + self.notification_list_view.blockSignals(True) try: - message, _, priority = self.cli_tracking.popleft() - match priority: - case 1: - self._add_notif_entry(message, "#1A8FBF", self._ICON_INFO) - case 2: - self._add_notif_entry(message, "#E7E147", self._ICON_WARN) - case 3: - self._add_notif_entry(message, "#CA4949", self._ICON_ERROR) - case _: - self._add_notif_entry(message, "#a4a4a4", self._ICON_INFO) + self._add_notif_entry(message, color, icon) self.model.setData(self.model.index(0), True, EntryListModel.EnableRole) finally: - self.update_buttons_list_widget.blockSignals(False) + self.notification_list_view.blockSignals(False) @QtCore.pyqtSlot(ListItem, name="on-item-clicked") def on_item_clicked(self, item: ListItem) -> None: @@ -118,7 +122,7 @@ def on_item_clicked(self, item: ListItem) -> None: @QtCore.pyqtSlot(str, str, int, bool, name="new-notication") def new_notication( self, - origin: str | None = None, + _origin: str | None = None, message: str = "", priority: int = 0, popup: bool = False, @@ -131,8 +135,7 @@ def new_notication( :param popup: sets if notification should appear as popup :type popup: bool """ - self.cli_tracking.append((message, origin, priority)) - self.model.delete_duplicates() + self._ingest_notification(message, priority) if popup: match priority: @@ -147,7 +150,6 @@ def new_notication( self.popup.new_message(message_type=msg_type, message=message, timeout=3000) - self.build_model_list() self.has_new_notification.emit(not self.isVisible()) def _add_notif_entry( @@ -168,13 +170,14 @@ def _add_notif_entry( allow_expand=True, notificate=False, color_left_icon=True, + text_left_padding=10, ) time = QtCore.QDateTime.currentDateTime().toString("hh:mm:ss") item._cache[-1] = time - self.model.add_item(item) + self.model.insert_item(0, item) def _setupUI(self) -> None: - """Setup UI for updatePage""" + """Setup UI for the notification panel""" sizePolicy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.MinimumExpanding, @@ -184,16 +187,16 @@ def _setupUI(self) -> None: font = QtGui.QFont() font.setPointSize(20) self.setSizePolicy(sizePolicy) - self.setObjectName("updatePage") + self.setObjectName("notificationPage") self.setStyleSheet( - """#updatePage { + """#notificationPage { background-image: url(:/background/media/1st_background.png); }""" ) self.setLayoutDirection(QtCore.Qt.LayoutDirection.LeftToRight) - self.update_page_content_layout = QtWidgets.QVBoxLayout() + self.content_layout = QtWidgets.QVBoxLayout() self.setMinimumSize(800, 480) - self.update_page_content_layout.setContentsMargins(15, 15, 15, 15) + self.content_layout.setContentsMargins(15, 15, 15, 15) self.header_content_layout = QtWidgets.QHBoxLayout() self.header_content_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) @@ -224,75 +227,75 @@ def _setupUI(self) -> None: self.header_content_layout.addWidget( self.header_title, alignment=QtCore.Qt.AlignmentFlag.AlignCenter ) - self.update_back_btn = IconButton(self) - self.update_back_btn.setMinimumSize(QtCore.QSize(60, 60)) - self.update_back_btn.setMaximumSize(QtCore.QSize(60, 60)) - self.update_back_btn.setFlat(True) - self.update_back_btn.setPixmap(QtGui.QPixmap(":/ui/media/btn_icons/back.svg")) + self.back_btn = IconButton(self) + self.back_btn.setMinimumSize(QtCore.QSize(60, 60)) + self.back_btn.setMaximumSize(QtCore.QSize(60, 60)) + self.back_btn.setFlat(True) + self.back_btn.setPixmap(QtGui.QPixmap(":/ui/media/btn_icons/back.svg")) self.header_content_layout.addWidget( - self.update_back_btn + self.back_btn ) # alignment=QtCore.Qt.AlignmentFlag.AlignCenter) - self.update_page_content_layout.addLayout(self.header_content_layout, 0) + self.content_layout.addLayout(self.header_content_layout, 0) self.main_content_layout = QtWidgets.QHBoxLayout() self.main_content_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.update_buttons_frame = BlocksCustomFrame(self) + self.list_frame = BlocksCustomFrame(self) - self.update_buttons_frame.setMinimumSize(QtCore.QSize(500, 380)) - self.update_buttons_frame.setMaximumSize(QtCore.QSize(560, 500)) + self.list_frame.setMinimumSize(QtCore.QSize(500, 380)) + self.list_frame.setMaximumSize(QtCore.QSize(560, 500)) - self.update_buttons_list_widget = QtWidgets.QListView(self.update_buttons_frame) - self.update_buttons_list_widget.setMouseTracking(True) - self.update_buttons_list_widget.setTabletTracking(True) + self.notification_list_view = QtWidgets.QListView(self.list_frame) + self.notification_list_view.setMouseTracking(True) + self.notification_list_view.setTabletTracking(True) - self.update_buttons_list_widget.setPalette(palette) - self.update_buttons_list_widget.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) - self.update_buttons_list_widget.setStyleSheet("background-color:transparent") - self.update_buttons_list_widget.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) - self.update_buttons_list_widget.setMinimumSize(self.update_buttons_frame.size()) - self.update_buttons_list_widget.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) - self.update_buttons_list_widget.setVerticalScrollBarPolicy( + self.notification_list_view.setPalette(palette) + self.notification_list_view.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + self.notification_list_view.setStyleSheet("background-color:transparent") + self.notification_list_view.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + self.notification_list_view.setMinimumSize(self.list_frame.size()) + self.notification_list_view.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self.notification_list_view.setVerticalScrollBarPolicy( QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.update_buttons_list_widget.setHorizontalScrollBarPolicy( + self.notification_list_view.setHorizontalScrollBarPolicy( QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - self.update_buttons_list_widget.setSizeAdjustPolicy( + self.notification_list_view.setSizeAdjustPolicy( QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents ) - self.update_buttons_list_widget.setAutoScroll(False) - self.update_buttons_list_widget.setProperty("showDropIndicator", False) - self.update_buttons_list_widget.setDefaultDropAction( + self.notification_list_view.setAutoScroll(False) + self.notification_list_view.setProperty("showDropIndicator", False) + self.notification_list_view.setDefaultDropAction( QtCore.Qt.DropAction.IgnoreAction ) - self.update_buttons_list_widget.setAlternatingRowColors(False) - self.update_buttons_list_widget.setSelectionMode( + self.notification_list_view.setAlternatingRowColors(False) + self.notification_list_view.setSelectionMode( QtWidgets.QAbstractItemView.SelectionMode.NoSelection ) - self.update_buttons_list_widget.setSelectionBehavior( + self.notification_list_view.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectionBehavior.SelectItems ) - self.update_buttons_list_widget.setVerticalScrollMode( + self.notification_list_view.setVerticalScrollMode( QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel ) - self.update_buttons_list_widget.setHorizontalScrollMode( + self.notification_list_view.setHorizontalScrollMode( QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel ) QtWidgets.QScroller.grabGesture( - self.update_buttons_list_widget, + self.notification_list_view, QtWidgets.QScroller.ScrollerGestureType.TouchGesture, ) QtWidgets.QScroller.grabGesture( - self.update_buttons_list_widget, + self.notification_list_view, QtWidgets.QScroller.ScrollerGestureType.LeftMouseButtonGesture, ) - self.update_buttons_layout = QtWidgets.QVBoxLayout() - self.update_buttons_layout.setContentsMargins(0, 0, 0, 0) - self.update_buttons_layout.addWidget(self.update_buttons_list_widget, 0) - self.update_buttons_frame.setLayout(self.update_buttons_layout) + self.list_frame_layout = QtWidgets.QVBoxLayout() + self.list_frame_layout.setContentsMargins(0, 0, 0, 0) + self.list_frame_layout.addWidget(self.notification_list_view, 0) + self.list_frame.setLayout(self.list_frame_layout) - self.main_content_layout.addWidget(self.update_buttons_frame) + self.main_content_layout.addWidget(self.list_frame) self.vlayout = QtWidgets.QVBoxLayout() self.vlayout.setContentsMargins(5, 5, 5, 5) @@ -409,5 +412,5 @@ def _setupUI(self) -> None: self.vlayout.addWidget(self.buttons_frame) self.main_content_layout.addLayout(self.vlayout) - self.update_page_content_layout.addLayout(self.main_content_layout, 1) - self.setLayout(self.update_page_content_layout) + self.content_layout.addLayout(self.main_content_layout, 1) + self.setLayout(self.content_layout) diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index a1a6377c..893585bc 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -13,12 +13,12 @@ class ListItem: _rfontsize: int = 0 _lfontsize: int = 0 - callback: typing.Optional[typing.Callable] = None + callback: typing.Callable | None = None color: str = "#dfdfdf" color_left_icon: bool = False - right_icon: typing.Optional[QtGui.QPixmap] = None - left_icon: typing.Optional[QtGui.QPixmap] = None + right_icon: QtGui.QPixmap | None = None + left_icon: QtGui.QPixmap | None = None selected: bool = False allow_check: bool = True @@ -31,9 +31,10 @@ class ListItem: height: int = 60 notificate: bool = False + text_left_padding: int = 0 # extra gap between the left icon and the text - # stores width and heitgh of the button so we dont need to recalculate it every time - _cache: typing.Dict[int, int] = field(default_factory=dict) + # cached per-width height, plus the -1 key for a notification's display timestamp (str) + _cache: dict[int, typing.Any] = field(default_factory=dict) def clear_cache(self): """Call this if text or font size changes dynamically""" @@ -68,28 +69,17 @@ def remove_item(self, item: ListItem) -> None: self.entries.pop(index) self.endRemoveRows() - def delete_duplicates(self) -> None: - """ - Removes items that have identical text, color, and - last time entry (get(-1)). - """ - seen_identifiers: set[tuple[str, str, str]] = set() - unique_entries: list[ListItem] = [] - - for item in self.entries: - text_val = item.text - color_val = item.color - time_val = item._cache.get(-1) - - identifier = (text_val, color_val, time_val) - - if identifier not in seen_identifiers: - unique_entries.append(item) - seen_identifiers.add(identifier) - - self.beginResetModel() - self.entries = unique_entries - self.endResetModel() + def refresh_last_if_duplicate(self, text: str, color: str) -> bool: + """Collapse a repeat of the most recent entry (O(1)) instead of inserting a new row.""" + if not self.entries: + return False + last = self.entries[0] + if last.text != text or last.color != color: + return False + last._cache[-1] = QtCore.QDateTime.currentDateTime().toString("hh:mm:ss") + idx = self.index(0) + self.dataChanged.emit(idx, idx) + return True def clear(self) -> None: """Clear model rows""" @@ -359,6 +349,7 @@ def sizeHint( left_reserved = 10 if item.left_icon: left_reserved = (base_h * 0.1) + ellipse_size + 8 + left_reserved += item.text_left_padding if item._lfontsize > 0 and item._lfontsize != option.font.pointSize(): f = QtGui.QFont(option.font) @@ -380,8 +371,7 @@ def sizeHint( right_reserved += ellipse_size text_avail_width = target_width - left_reserved - right_reserved - if text_avail_width < 50: - text_avail_width = 50 + text_avail_width = max(text_avail_width, 50) single_line_width = fm.horizontalAdvance(item.text) @@ -499,15 +489,16 @@ def paint( rect.right() - ellipse_size - ellipse_margin - rect.height() * 0.10 ) - text_rect = QtCore.QRectF( + text_left = ( rect.left() + left_margin - + (left_icon_rect.width() if item.left_icon else 0), + + (left_icon_rect.width() if item.left_icon else 0) + + item.text_left_padding + ) + text_rect = QtCore.QRectF( + text_left, rect.top(), - text_margin - - rect.left() - - left_margin - - (left_icon_rect.width() if item.left_icon else 0), + text_margin - text_left, rect.height(), ) diff --git a/tests/util/test_notification_page_unit.py b/tests/util/test_notification_page_unit.py index e0a7553c..e5e41b9c 100644 --- a/tests/util/test_notification_page_unit.py +++ b/tests/util/test_notification_page_unit.py @@ -38,9 +38,9 @@ def _mock_setup(self) -> None: - self.update_buttons_list_widget = QtWidgets.QListView() + self.notification_list_view = QtWidgets.QListView() for attr in ( - "update_back_btn", + "back_btn", "delete_btn", "delete_all_btn", "header_title", From c8590f2310ec4bd15f6e71634ce8dfb7185b0844 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 12:10:14 +0100 Subject: [PATCH 05/11] refactor(list-model): make icon-text spacing universal across all list-based pages --- BlocksScreen/lib/panels/widgets/notificationPage.py | 1 - BlocksScreen/lib/utils/list_model.py | 9 ++++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index bfb29ae3..da7ab5e7 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -170,7 +170,6 @@ def _add_notif_entry( allow_expand=True, notificate=False, color_left_icon=True, - text_left_padding=10, ) time = QtCore.QDateTime.currentDateTime().toString("hh:mm:ss") item._cache[-1] = time diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 893585bc..28d958a5 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -3,6 +3,8 @@ from PyQt6 import QtCore, QtGui, QtWidgets # pylint: disable=import-error +_TEXT_LEFT_PADDING = 10 # gap between the left icon and the text, all list pages + @dataclass(slots=True) class ListItem: @@ -31,7 +33,6 @@ class ListItem: height: int = 60 notificate: bool = False - text_left_padding: int = 0 # extra gap between the left icon and the text # cached per-width height, plus the -1 key for a notification's display timestamp (str) _cache: dict[int, typing.Any] = field(default_factory=dict) @@ -348,8 +349,7 @@ def sizeHint( left_reserved = 10 if item.left_icon: - left_reserved = (base_h * 0.1) + ellipse_size + 8 - left_reserved += item.text_left_padding + left_reserved = (base_h * 0.1) + ellipse_size + 8 + _TEXT_LEFT_PADDING if item._lfontsize > 0 and item._lfontsize != option.font.pointSize(): f = QtGui.QFont(option.font) @@ -492,8 +492,7 @@ def paint( text_left = ( rect.left() + left_margin - + (left_icon_rect.width() if item.left_icon else 0) - + item.text_left_padding + + (left_icon_rect.width() + _TEXT_LEFT_PADDING if item.left_icon else 0) ) text_rect = QtCore.QRectF( text_left, From ecb87c60f96890c7a63941e9e1e5cd479bfc030c Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 12:20:23 +0100 Subject: [PATCH 06/11] fix(ui): keep expand-toggle clicks selecting the item; make icon-text padding universal --- BlocksScreen/lib/utils/list_model.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 28d958a5..8cc3b14a 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -608,6 +608,16 @@ def editorEvent( # pylint: disable=invalid-name ): new_state = not item.is_expanded model.setData(index, new_state, EntryListModel.ExpandRole) + # Toggling expand must also select — the arrow covers most of + # the row, so a tap there would otherwise silently skip + # selection and leave the info panel stale (first-click bug). + if self.prev_index != index.row(): + prev_index: QtCore.QModelIndex = model.index(self.prev_index) + if prev_index.isValid(): + model.setData(prev_index, False, EntryListModel.EnableRole) + self.prev_index = index.row() + model.setData(index, True, EntryListModel.EnableRole) + self.item_selected.emit(item) return True if self.prev_index != index.row(): From af3e897887ba4fdb68e071c92e8fb5c124139289 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 12:30:01 +0100 Subject: [PATCH 07/11] fix(notifications): keep info panel in sync with selection (expand-click, dedupe refresh, delete/reset), add universal icon-text padding --- .../lib/panels/widgets/notificationPage.py | 31 +++++++++++++++++-- BlocksScreen/lib/utils/list_model.py | 1 + 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index da7ab5e7..8f65ad15 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -53,14 +53,19 @@ def show_notification_panel( self.show() self.raise_() self.has_new_notification.emit(False) + if self.model.entries: + self._select_row(0) def delete_selected_item(self) -> None: """Deletes currently selected item from the list view""" if self.selected_item is None: return self.model.remove_item(self.selected_item) - self.delete_btn.setEnabled(False) self.selected_item = None + if self.model.entries: + self._select_row(0) + else: + self._clear_info_box() def reset_view_model(self) -> None: """Clears items from ListView @@ -68,8 +73,16 @@ def reset_view_model(self) -> None: """ self.model.clear() self.entry_delegate.clear() + self.selected_item = None + self._clear_info_box() self.has_new_notification.emit(False) + def _clear_info_box(self) -> None: + """Resets the info box to its empty-list default (no item selected).""" + self.delete_btn.setEnabled(False) + self.type_label.setText("N/A") + self.time_label.setText("N/A") + def _on_rows_inserted( self, _parent: QtCore.QModelIndex, first: int, _last: int ) -> None: @@ -77,6 +90,19 @@ def _on_rows_inserted( if first <= self.entry_delegate.prev_index: self.entry_delegate.prev_index += 1 + def _select_row(self, row: int) -> None: + """Selects *row*, clearing the previous selection and refreshing the info box.""" + index = self.model.index(row) + if not index.isValid(): + return + if self.entry_delegate.prev_index != row: + prev_index = self.model.index(self.entry_delegate.prev_index) + if prev_index.isValid(): + self.model.setData(prev_index, False, EntryListModel.EnableRole) + self.entry_delegate.prev_index = row + self.model.setData(index, True, EntryListModel.EnableRole) + self.on_item_clicked(index.data(QtCore.Qt.ItemDataRole.UserRole)) + def _ingest_notification(self, message: str, priority: int) -> None: """Adds *message* to the model, collapsing a repeat of the last entry (moonraker echo spam).""" match priority: @@ -90,12 +116,13 @@ def _ingest_notification(self, message: str, priority: int) -> None: color, icon = "#a4a4a4", self._ICON_INFO if self.model.refresh_last_if_duplicate(message, color): + self._select_row(0) return self.notification_list_view.blockSignals(True) try: self._add_notif_entry(message, color, icon) - self.model.setData(self.model.index(0), True, EntryListModel.EnableRole) + self._select_row(0) finally: self.notification_list_view.blockSignals(False) diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 8cc3b14a..437a2bfa 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -264,6 +264,7 @@ def setData(self, index: QtCore.QModelIndex, value: typing.Any, role: int) -> bo item.is_expanded = value self.layoutChanged.emit() self.dataChanged.emit(index, index, [EntryListModel.ExpandRole]) + return True if role == QtCore.Qt.ItemDataRole.UserRole: self.dataChanged.emit(index, index, [QtCore.Qt.ItemDataRole.UserRole]) return True From ade057b31dae9d98075b00bc29da74c4113c8738 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 15:27:26 +0100 Subject: [PATCH 08/11] fix(ui): center notification icon with other header icons --- .../lib/panels/widgets/notificationPage.py | 25 ++++++++----------- BlocksScreen/lib/ui/mainWindow.ui | 2 +- BlocksScreen/lib/ui/mainWindow_ui.py | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index 8f65ad15..df92fc2b 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -332,36 +332,31 @@ def _setupUI(self) -> None: self.info_box_layout = QtWidgets.QGridLayout(self.info_frame) self.info_box_layout.setContentsMargins(0, 0, 0, 0) - self.info_box_layout.addItem( - QtWidgets.QSpacerItem( - 20, - 20, - QtWidgets.QSizePolicy.Policy.Minimum, - QtWidgets.QSizePolicy.Policy.Minimum, - ), - 0, - 0, - ) - self.type_title = QtWidgets.QLabel(self.info_frame) self.type_title.setText("Type:") self.type_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_box_layout.addWidget(self.type_title, 1, 0) + self.info_box_layout.addWidget(self.type_title, 1, 1) self.type_label = QtWidgets.QLabel(self.info_frame) self.type_label.setText("N/A") self.type_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_box_layout.addWidget(self.type_label, 1, 1) + self.info_box_layout.addWidget(self.type_label, 1, 2) self.time_title = QtWidgets.QLabel(self.info_frame) self.time_title.setText("Time:") self.time_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_box_layout.addWidget(self.time_title, 2, 0) + self.info_box_layout.addWidget(self.time_title, 2, 1) self.time_label = QtWidgets.QLabel(self.info_frame) self.time_label.setText("N/A") self.time_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_box_layout.addWidget(self.time_label, 2, 1) + self.info_box_layout.addWidget(self.time_label, 2, 2) + + # Stretch columns/rows surrounding the content block to center it in the frame. + self.info_box_layout.setColumnStretch(0, 1) + self.info_box_layout.setColumnStretch(3, 1) + self.info_box_layout.setRowStretch(0, 1) + self.info_box_layout.setRowStretch(3, 1) self.type_title.setFont(font) self.type_title.setStyleSheet("color:#FFFFFF") diff --git a/BlocksScreen/lib/ui/mainWindow.ui b/BlocksScreen/lib/ui/mainWindow.ui index 451677f2..f73a9794 100644 --- a/BlocksScreen/lib/ui/mainWindow.ui +++ b/BlocksScreen/lib/ui/mainWindow.ui @@ -531,7 +531,7 @@ QPushButton:pressed{ 0 - + diff --git a/BlocksScreen/lib/ui/mainWindow_ui.py b/BlocksScreen/lib/ui/mainWindow_ui.py index e467dacf..259b6367 100644 --- a/BlocksScreen/lib/ui/mainWindow_ui.py +++ b/BlocksScreen/lib/ui/mainWindow_ui.py @@ -239,7 +239,7 @@ def setupUi(self, MainWindow): self.notification_btn.setFlat(True) self.notification_btn.setProperty("icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/notification.svg")) self.notification_btn.setObjectName("notification_btn") - self.header_main_layout.addWidget(self.notification_btn, 0, QtCore.Qt.AlignmentFlag.AlignLeft) + self.header_main_layout.addWidget(self.notification_btn, 0, QtCore.Qt.AlignmentFlag.AlignHCenter) self.extruder_temp_display = DisplayButton(parent=self.main_header_layout) self.extruder_temp_display.setMinimumSize(QtCore.QSize(140, 60)) self.extruder_temp_display.setMaximumSize(QtCore.QSize(160, 60)) From 489a1bdab2b0b03abb410baf97a6339552e31a53 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 15:34:01 +0100 Subject: [PATCH 09/11] fix(ui): remove size-policy override to align header icons, restore info-box row spacing --- BlocksScreen/lib/panels/widgets/notificationPage.py | 4 +++- BlocksScreen/lib/ui/mainWindow.ui | 6 ------ BlocksScreen/lib/ui/mainWindow_ui.py | 5 ----- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index df92fc2b..7440c25b 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -352,10 +352,12 @@ def _setupUI(self) -> None: self.time_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.info_box_layout.addWidget(self.time_label, 2, 2) - # Stretch columns/rows surrounding the content block to center it in the frame. + # Equal stretch keeps the Type/Time gap instead of collapsing it into the padding. self.info_box_layout.setColumnStretch(0, 1) self.info_box_layout.setColumnStretch(3, 1) self.info_box_layout.setRowStretch(0, 1) + self.info_box_layout.setRowStretch(1, 1) + self.info_box_layout.setRowStretch(2, 1) self.info_box_layout.setRowStretch(3, 1) self.type_title.setFont(font) diff --git a/BlocksScreen/lib/ui/mainWindow.ui b/BlocksScreen/lib/ui/mainWindow.ui index f73a9794..79af7de2 100644 --- a/BlocksScreen/lib/ui/mainWindow.ui +++ b/BlocksScreen/lib/ui/mainWindow.ui @@ -533,12 +533,6 @@ QPushButton:pressed{ - - - 1 - 1 - - 60 diff --git a/BlocksScreen/lib/ui/mainWindow_ui.py b/BlocksScreen/lib/ui/mainWindow_ui.py index 259b6367..3e9e3a35 100644 --- a/BlocksScreen/lib/ui/mainWindow_ui.py +++ b/BlocksScreen/lib/ui/mainWindow_ui.py @@ -227,11 +227,6 @@ def setupUi(self, MainWindow): self.header_main_layout.setSpacing(10) self.header_main_layout.setObjectName("header_main_layout") self.notification_btn = IconButton(parent=self.main_header_layout) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed) - sizePolicy.setHorizontalStretch(1) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.notification_btn.sizePolicy().hasHeightForWidth()) - self.notification_btn.setSizePolicy(sizePolicy) self.notification_btn.setMinimumSize(QtCore.QSize(60, 60)) self.notification_btn.setMaximumSize(QtCore.QSize(60, 60)) self.notification_btn.setText("") From f5b472bfe9ff21e4f71508f10e137bd63d89d353 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Wed, 9 Sep 2026 15:37:46 +0100 Subject: [PATCH 10/11] fix(notifications): widen Type/Time gap in info box via weighted row stretch --- BlocksScreen/lib/panels/widgets/notificationPage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/notificationPage.py b/BlocksScreen/lib/panels/widgets/notificationPage.py index 7440c25b..7664efa9 100644 --- a/BlocksScreen/lib/panels/widgets/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/notificationPage.py @@ -352,12 +352,12 @@ def _setupUI(self) -> None: self.time_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.info_box_layout.addWidget(self.time_label, 2, 2) - # Equal stretch keeps the Type/Time gap instead of collapsing it into the padding. + # Rows 1/2 outweigh the outer padding so most slack goes into the Type/Time gap. self.info_box_layout.setColumnStretch(0, 1) self.info_box_layout.setColumnStretch(3, 1) self.info_box_layout.setRowStretch(0, 1) - self.info_box_layout.setRowStretch(1, 1) - self.info_box_layout.setRowStretch(2, 1) + self.info_box_layout.setRowStretch(1, 2) + self.info_box_layout.setRowStretch(2, 2) self.info_box_layout.setRowStretch(3, 1) self.type_title.setFont(font) From 900daf97215f9203d0eda47be98fd3feb1898863 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 29 Sep 2026 10:25:28 +0100 Subject: [PATCH 11/11] fix(notification): keep viewed entry selected while open, drop setFlat, type cache as Any --- .../panels/widgets/MainWindow/notificationPage.py | 13 ++++++++++--- BlocksScreen/lib/utils/list_model.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/MainWindow/notificationPage.py b/BlocksScreen/lib/panels/widgets/MainWindow/notificationPage.py index 51c99f7f..1af97842 100644 --- a/BlocksScreen/lib/panels/widgets/MainWindow/notificationPage.py +++ b/BlocksScreen/lib/panels/widgets/MainWindow/notificationPage.py @@ -103,6 +103,12 @@ def _select_row(self, row: int) -> None: self.model.setData(index, True, EntryListModel.EnableRole) self.on_item_clicked(index.data(QtCore.Qt.ItemDataRole.UserRole)) + def _should_select_newest(self) -> bool: + """False while the user is viewing another entry, so it is not deselected.""" + if not self.isVisible() or self.selected_item is None: + return True + return self.selected_item is self.model.entries[0] + def _ingest_notification(self, message: str, priority: int) -> None: """Adds *message* to the model, collapsing a repeat of the last entry (moonraker echo spam).""" match priority: @@ -116,13 +122,15 @@ def _ingest_notification(self, message: str, priority: int) -> None: color, icon = "#a4a4a4", self._ICON_INFO if self.model.refresh_last_if_duplicate(message, color): - self._select_row(0) + if self._should_select_newest(): + self._select_row(0) return self.notification_list_view.blockSignals(True) try: self._add_notif_entry(message, color, icon) - self._select_row(0) + if self._should_select_newest(): + self._select_row(0) finally: self.notification_list_view.blockSignals(False) @@ -256,7 +264,6 @@ def _setup_ui(self) -> None: self.back_btn = IconButton(self) self.back_btn.setMinimumSize(QtCore.QSize(60, 60)) self.back_btn.setMaximumSize(QtCore.QSize(60, 60)) - self.back_btn.setFlat(True) self.back_btn.setPixmap(QtGui.QPixmap(":/ui/media/btn_icons/back.svg")) self.header_content_layout.addWidget( self.back_btn diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 19744249..87d55b9d 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -36,7 +36,7 @@ class ListItem: notificate: bool = False # per-width height cache; the -1 key holds a notification's display timestamp - _cache: dict[int, int | str] = field(default_factory=dict) + _cache: dict[int, typing.Any] = field(default_factory=dict) class EntryListModel(QtCore.QAbstractListModel):