From a3b8769d867cd17db7c0a633d7f977885961fba3 Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Fri, 2 Oct 2026 17:28:58 +0100 Subject: [PATCH 1/3] fix(notifications): keep line breaks in notification rows and split FlowGuard clog/tangle --- BlocksScreen/lib/klipper_message_filter.py | 9 +++++- BlocksScreen/lib/utils/list_model.py | 32 +++++++++++----------- tests/lib/test_klipper_message_filter.py | 13 +++++++++ tests/util/test_entry_delegate_unit.py | 15 ++++++++++ 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/BlocksScreen/lib/klipper_message_filter.py b/BlocksScreen/lib/klipper_message_filter.py index ea16b1bf..db7c22aa 100644 --- a/BlocksScreen/lib/klipper_message_filter.py +++ b/BlocksScreen/lib/klipper_message_filter.py @@ -287,7 +287,14 @@ def _re(pattern: str) -> Callable[[str], bool]: ), MessageRule( source=MessageSource.GCODE_ERROR, - matcher=_sub("flowguard"), + matcher=_sub("flowguard detected a clog"), + display="Filament Clog Detected", + hint="Check the extruder for a clog", + severity=Severity.ERROR, + ), + MessageRule( + source=MessageSource.GCODE_ERROR, + matcher=_sub("flowguard detected a tangle"), display="Filament Tangle Detected", hint="Clear the tangle", severity=Severity.ERROR, diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 87d55b9d..dae04431 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -382,12 +382,17 @@ def sizeHint( text_avail_width = max(target_width - left_reserved - right_reserved, 50) - single_line_width = fm.horizontalAdvance(item.text) + collapsed_h = int(item.height * 1.1) + lines = item.text.split("\n") + # paint() insets the row by 2px per side before fitting lines + max_lines = max(1, (collapsed_h - 4) // fm.lineSpacing()) - item.needs_expansion = single_line_width > text_avail_width + item.needs_expansion = len(lines) > max_lines or any( + fm.horizontalAdvance(line) > text_avail_width for line in lines + ) if not item.is_expanded: - return QtCore.QSize(target_width, int(item.height * 1.1)) + return QtCore.QSize(target_width, collapsed_h) text_rect = fm.boundingRect( QtCore.QRect(0, 0, int(text_avail_width), 0), @@ -421,7 +426,6 @@ def paint( if item.allow_expand and item.needs_expansion: item.right_icon = self._expand_arrow(item.is_expanded) - # Background Color pressed_color = QtGui.QColor("#1A8FBF") pressed_color.setAlpha(90 if item.selected else 20) @@ -429,9 +433,6 @@ def paint( painter.setBrush(pressed_color) painter.fillPath(path, pressed_color) - # Geometry Calc - - # ICON SPACEEE ellipse_size = item.height * 0.8 ellipse_margin = (item.height - ellipse_size) / 2 ellipse_rect = QtCore.QRectF( @@ -503,14 +504,14 @@ def paint( - left_margin ) - text = item.text.replace("\n", "") - # Logic: If not expanded, OR if expansion is not needed, draw single line if not item.is_expanded: - max_main_text_width = right_text_x - left_margin - text = metrics.elidedText( - text, - QtCore.Qt.TextElideMode.ElideRight, - int(max_main_text_width), + max_main_text_width = int(right_text_x - left_margin) + max_lines = max(1, int(text_rect.height()) // metrics.lineSpacing()) + text = "\n".join( + metrics.elidedText( + line, QtCore.Qt.TextElideMode.ElideRight, max_main_text_width + ) + for line in item.text.split("\n")[:max_lines] ) painter.drawText( text_rect, @@ -518,13 +519,12 @@ def paint( text, ) else: - # Expanded mode painter.drawText( text_rect, QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.TextFlag.TextWordWrap, - text, + item.text, ) if item.right_text: diff --git a/tests/lib/test_klipper_message_filter.py b/tests/lib/test_klipper_message_filter.py index fcea2472..3597e99d 100644 --- a/tests/lib/test_klipper_message_filter.py +++ b/tests/lib/test_klipper_message_filter.py @@ -256,6 +256,19 @@ def test_re_no_match() -> None: Severity.ERROR, ), (MessageSource.GCODE_ERROR, "gate empty", "Spool Empty", Severity.WARNING), + ( + MessageSource.GCODE_ERROR, + "FlowGuard detected a clog.\nReason for trip: Compression stuck after " + "409.04 mm motion (triggering parameter: flowguard_max_relief)", + "Filament Clog Detected", + Severity.ERROR, + ), + ( + MessageSource.GCODE_ERROR, + "FlowGuard detected a tangle.\nReason for trip: Tension stuck", + "Filament Tangle Detected", + Severity.ERROR, + ), # Gcode Echo ( MessageSource.GCODE_ECHO, diff --git a/tests/util/test_entry_delegate_unit.py b/tests/util/test_entry_delegate_unit.py index fadee006..0d433374 100644 --- a/tests/util/test_entry_delegate_unit.py +++ b/tests/util/test_entry_delegate_unit.py @@ -198,3 +198,18 @@ def test_arrow_ignored_when_expansion_not_needed(self, delegate, model): x, y = _arrow_center(option) assert _tap(delegate, model, 0, x, y, option) is True assert model.data(model.index(0), EntryListModel.ExpandRole) is False + + +class TestNeedsExpansion: + def _size_hint(self, delegate, text): + item = ListItem(text=text, height=ROW_H) + model = EntryListModel([item]) + delegate.sizeHint(_option(0), model.index(0)) + return item + + def test_short_single_line_fits(self, delegate): + assert self._size_hint(delegate, "A").needs_expansion is False + + def test_more_lines_than_row_holds_needs_expansion(self, delegate): + text = "\n".join("line" for _ in range(20)) + assert self._size_hint(delegate, text).needs_expansion is True From 8af873080cbb4adfd2d93f15aeff5372a954d41f Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 6 Oct 2026 15:27:53 +0100 Subject: [PATCH 2/3] fix(list): share text rect between sizeHint and paint, elide folded overflow lines --- BlocksScreen/lib/utils/list_model.py | 82 +++++++++++--------------- tests/util/test_entry_delegate_unit.py | 25 ++++++++ 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index dae04431..7b8d5c27 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -352,15 +352,6 @@ def sizeHint( item: ListItem = index.data(QtCore.Qt.ItemDataRole.UserRole) target_width = option.rect.width() - base_h = item.height - ellipse_size = base_h * 0.8 - - right_reserved = base_h - - left_reserved = 10 - if item.left_icon: - 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) f.setPointSize(item._lfontsize) @@ -368,6 +359,7 @@ def sizeHint( else: fm = option.fontMetrics + right_text_w = 0 if item.right_text: if item._rfontsize > 0 and item._rfontsize != option.font.pointSize(): fr = QtGui.QFont(option.font) @@ -375,17 +367,17 @@ def sizeHint( fmr = QtGui.QFontMetrics(fr) else: fmr = option.fontMetrics - right_reserved += fmr.horizontalAdvance(item.right_text) + 10 - - if item.right_icon: - right_reserved += ellipse_size - - text_avail_width = max(target_width - left_reserved - right_reserved, 50) + right_text_w = fmr.horizontalAdvance(item.right_text) collapsed_h = int(item.height * 1.1) + text_rect = self._text_rect( + item, + QtCore.QRect(option.rect.x(), option.rect.y(), target_width, collapsed_h), + right_text_w, + ) + text_avail_width = max(int(text_rect.width()), 50) lines = item.text.split("\n") - # paint() insets the row by 2px per side before fitting lines - max_lines = max(1, (collapsed_h - 4) // fm.lineSpacing()) + max_lines = max(1, int(text_rect.height()) // fm.lineSpacing()) item.needs_expansion = len(lines) > max_lines or any( fm.horizontalAdvance(line) > text_avail_width for line in lines @@ -394,15 +386,34 @@ def sizeHint( if not item.is_expanded: return QtCore.QSize(target_width, collapsed_h) - text_rect = fm.boundingRect( - QtCore.QRect(0, 0, int(text_avail_width), 0), + bounds = fm.boundingRect( + QtCore.QRect(0, 0, text_avail_width, 0), QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.TextFlag.TextWordWrap, item.text, ) - final_height = max(item.height, text_rect.height() - 1) + final_height = max(item.height, bounds.height() - 1) return QtCore.QSize(target_width, int(final_height * 1.2)) + @staticmethod + def _text_rect( + item: ListItem, row: QtCore.QRect, right_text_w: int + ) -> QtCore.QRectF: + """Main-text area of *row*, shared by sizeHint and paint so overflow matches elision.""" + rect = row.adjusted(2, 2, -2, -2) + icon = item.height * 0.8 + left = rect.left() + 10 + (icon + _TEXT_LEFT_PADDING if item.left_icon else 0) + right = rect.right() - item.height * 0.1 - icon - right_text_w - 10 + return QtCore.QRectF(left, rect.top(), right - left, rect.height()) + + @staticmethod + def _collapsed_lines(text: str, max_lines: int) -> list[str]: + """Lines a collapsed row draws; overflow folds into the last so elision marks the cut.""" + lines = text.split("\n") + if len(lines) > max_lines: + lines[max_lines - 1 :] = [" ".join(lines[max_lines - 1 :])] + return lines + def paint( self, painter: QtGui.QPainter, @@ -448,7 +459,6 @@ def paint( ) painter.drawPixmap(ellipse_rect.toRect(), icon_scaled) - left_margin = 10 left_icon_rect = QtCore.QRectF( rect.left() + ellipse_margin, rect.top() + ellipse_margin, @@ -466,22 +476,6 @@ def paint( left_pixmap = self._get_scaled(item.left_icon, icon_size) painter.drawPixmap(left_icon_rect.toRect(), left_pixmap) - text_margin = int( - rect.right() - ellipse_size - ellipse_margin - rect.height() * 0.10 - ) - - text_left = ( - rect.left() - + left_margin - + (left_icon_rect.width() + _TEXT_LEFT_PADDING if item.left_icon else 0) - ) - text_rect = QtCore.QRectF( - text_left, - rect.top(), - text_margin - text_left, - rect.height(), - ) - painter.setPen(QtGui.QColor(255, 255, 255)) _font = painter.font() @@ -497,21 +491,17 @@ def paint( right_metrics = QtGui.QFontMetrics(right_font) - right_text_x = ( - ellipse_rect.right() - - right_metrics.horizontalAdvance(item.right_text) - - left_icon_rect.width() - - left_margin + text_rect = self._text_rect( + item, option.rect, right_metrics.horizontalAdvance(item.right_text) ) if not item.is_expanded: - max_main_text_width = int(right_text_x - left_margin) max_lines = max(1, int(text_rect.height()) // metrics.lineSpacing()) text = "\n".join( metrics.elidedText( - line, QtCore.Qt.TextElideMode.ElideRight, max_main_text_width + line, QtCore.Qt.TextElideMode.ElideRight, int(text_rect.width()) ) - for line in item.text.split("\n")[:max_lines] + for line in self._collapsed_lines(item.text, max_lines) ) painter.drawText( text_rect, @@ -531,7 +521,7 @@ def paint( painter.setFont(right_font) painter.setPen(QtGui.QColor(160, 160, 160)) painter.drawText( - int(right_text_x), + int(text_rect.right()), int( ellipse_rect.top() + (ellipse_rect.height() + right_metrics.ascent()) / 2 diff --git a/tests/util/test_entry_delegate_unit.py b/tests/util/test_entry_delegate_unit.py index 0d433374..ee051fd9 100644 --- a/tests/util/test_entry_delegate_unit.py +++ b/tests/util/test_entry_delegate_unit.py @@ -213,3 +213,28 @@ def test_short_single_line_fits(self, delegate): def test_more_lines_than_row_holds_needs_expansion(self, delegate): text = "\n".join("line" for _ in range(20)) assert self._size_hint(delegate, text).needs_expansion is True + + def test_overflow_uses_paint_text_width(self, delegate): + item = ListItem(text="", height=ROW_H, left_icon=QtGui.QPixmap(1, 1)) + row = QtCore.QRect(0, 0, ROW_W, int(ROW_H * 1.1)) + width = int(EntryDelegate._text_rect(item, row, 0).width()) + fm = _option(0).fontMetrics + fits = "x" + while fm.horizontalAdvance(fits + "x") <= width: + fits += "x" + model = EntryListModel([item]) + for text, expected in ((fits, False), (fits + "x", True)): + item.text = text + delegate.sizeHint(_option(0), model.index(0)) + assert item.needs_expansion is expected + + +class TestCollapsedLines: + def test_lines_that_fit_are_kept(self): + assert EntryDelegate._collapsed_lines("a\nb", 2) == ["a", "b"] + + def test_overflow_folds_into_last_line(self): + assert EntryDelegate._collapsed_lines("a\nb\nc\nd", 2) == ["a", "b c d"] + + def test_single_line_row_joins_everything(self): + assert EntryDelegate._collapsed_lines("a\nb", 1) == ["a b"] From b41958a2698c9b4e65443438472db3718ae4c63e Mon Sep 17 00:00:00 2001 From: Guilherme Costa Date: Tue, 6 Oct 2026 16:58:25 +0100 Subject: [PATCH 3/3] fix(list-model): match sizeHint fonts and text rect to paint, gap right text, harden delegate tests --- BlocksScreen/lib/utils/list_model.py | 52 ++++++------- tests/util/test_entry_delegate_unit.py | 102 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 29 deletions(-) diff --git a/BlocksScreen/lib/utils/list_model.py b/BlocksScreen/lib/utils/list_model.py index 7b8d5c27..46ca9f83 100644 --- a/BlocksScreen/lib/utils/list_model.py +++ b/BlocksScreen/lib/utils/list_model.py @@ -5,6 +5,7 @@ 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 +_RIGHT_TEXT_GAP = 10 # gap between the elided main text and the right text @dataclass(slots=True) @@ -352,22 +353,9 @@ def sizeHint( item: ListItem = index.data(QtCore.Qt.ItemDataRole.UserRole) target_width = option.rect.width() - if item._lfontsize > 0 and item._lfontsize != option.font.pointSize(): - f = QtGui.QFont(option.font) - f.setPointSize(item._lfontsize) - fm = QtGui.QFontMetrics(f) - else: - fm = option.fontMetrics - - right_text_w = 0 - if item.right_text: - if item._rfontsize > 0 and item._rfontsize != option.font.pointSize(): - fr = QtGui.QFont(option.font) - fr.setPointSize(item._rfontsize) - fmr = QtGui.QFontMetrics(fr) - else: - fmr = option.fontMetrics - right_text_w = fmr.horizontalAdvance(item.right_text) + font, right_font = self._fonts(item, option.font) + fm = QtGui.QFontMetrics(font) + right_text_w = QtGui.QFontMetrics(right_font).horizontalAdvance(item.right_text) collapsed_h = int(item.height * 1.1) text_rect = self._text_rect( @@ -395,23 +383,37 @@ def sizeHint( final_height = max(item.height, bounds.height() - 1) return QtCore.QSize(target_width, int(final_height * 1.2)) + @staticmethod + def _fonts(item: ListItem, base: QtGui.QFont) -> tuple[QtGui.QFont, QtGui.QFont]: + """Main and right-text fonts of *item*, shared by sizeHint and paint.""" + font = QtGui.QFont(base) + if item._lfontsize > 0: + font.setPointSize(item._lfontsize) + right_font = QtGui.QFont(font) + if item._rfontsize > 0: + right_font.setPointSize(item._rfontsize) + return font, right_font + @staticmethod def _text_rect( item: ListItem, row: QtCore.QRect, right_text_w: int ) -> QtCore.QRectF: - """Main-text area of *row*, shared by sizeHint and paint so overflow matches elision.""" + """Main-text area of *row*; shared so overflow matches what paint elides.""" rect = row.adjusted(2, 2, -2, -2) icon = item.height * 0.8 left = rect.left() + 10 + (icon + _TEXT_LEFT_PADDING if item.left_icon else 0) right = rect.right() - item.height * 0.1 - icon - right_text_w - 10 + if item.right_text: + right -= _RIGHT_TEXT_GAP return QtCore.QRectF(left, rect.top(), right - left, rect.height()) @staticmethod def _collapsed_lines(text: str, max_lines: int) -> list[str]: - """Lines a collapsed row draws; overflow folds into the last so elision marks the cut.""" + """Collapsed-row lines; overflow lines join the last one, blanks dropped.""" lines = text.split("\n") if len(lines) > max_lines: - lines[max_lines - 1 :] = [" ".join(lines[max_lines - 1 :])] + tail = [line.strip() for line in lines[max_lines - 1 :] if line.strip()] + lines[max_lines - 1 :] = [" ".join(tail)] return lines def paint( @@ -478,17 +480,9 @@ def paint( painter.setPen(QtGui.QColor(255, 255, 255)) - _font = painter.font() - if item._lfontsize > 0: - _font.setPointSize(item._lfontsize) + _font, right_font = self._fonts(item, painter.font()) painter.setFont(_font) - metrics = QtGui.QFontMetrics(_font) - - right_font = QtGui.QFont(_font) - if item._rfontsize > 0: - right_font.setPointSize(item._rfontsize) - right_metrics = QtGui.QFontMetrics(right_font) text_rect = self._text_rect( @@ -521,7 +515,7 @@ def paint( painter.setFont(right_font) painter.setPen(QtGui.QColor(160, 160, 160)) painter.drawText( - int(text_rect.right()), + int(text_rect.right() + _RIGHT_TEXT_GAP), int( ellipse_rect.top() + (ellipse_rect.height() + right_metrics.ascent()) / 2 diff --git a/tests/util/test_entry_delegate_unit.py b/tests/util/test_entry_delegate_unit.py index ee051fd9..40ecc3ec 100644 --- a/tests/util/test_entry_delegate_unit.py +++ b/tests/util/test_entry_delegate_unit.py @@ -228,6 +228,27 @@ def test_overflow_uses_paint_text_width(self, delegate): delegate.sizeHint(_option(0), model.index(0)) assert item.needs_expansion is expected + @pytest.mark.parametrize( + "option_rect", + [ + QtCore.QRect(0, 0, ROW_W, ROW_H), + # A real QListView passes the whole viewport, not the row. + QtCore.QRect(0, 0, 498, 284), + ], + ) + def test_line_count_limit_is_the_collapsed_row(self, delegate, option_rect): + option = QtWidgets.QStyleOptionViewItem() + option.rect = option_rect + item = ListItem(text="", height=ROW_H) + row = QtCore.QRect(0, 0, option_rect.width(), int(ROW_H * 1.1)) + text_h = int(EntryDelegate._text_rect(item, row, 0).height()) + max_lines = max(1, text_h // option.fontMetrics.lineSpacing()) + model = EntryListModel([item]) + for count, expected in ((max_lines, False), (max_lines + 1, True)): + item.text = "\n".join("x" * count) + delegate.sizeHint(option, model.index(0)) + assert item.needs_expansion is expected + class TestCollapsedLines: def test_lines_that_fit_are_kept(self): @@ -238,3 +259,84 @@ def test_overflow_folds_into_last_line(self): def test_single_line_row_joins_everything(self): assert EntryDelegate._collapsed_lines("a\nb", 1) == ["a b"] + + def test_blank_overflow_lines_are_dropped(self): + assert EntryDelegate._collapsed_lines("a\n\n\nb", 1) == ["a b"] + assert EntryDelegate._collapsed_lines("a\nb\n \nc", 2) == ["a", "b c"] + + +class _RecordingPainter(QtGui.QPainter): + """Real painter that also records every drawText call.""" + + def __init__(self, device): + super().__init__(device) + self.calls = [] + + def drawText(self, *args): + self.calls.append(args) + super().drawText(*args) + + +def _paint(delegate, item): + """Paint *item* at its sizeHint size; return the recorded drawText calls.""" + model = EntryListModel([item]) + size = delegate.sizeHint(_option(0), model.index(0)) + option = QtWidgets.QStyleOptionViewItem() + option.rect = QtCore.QRect(QtCore.QPoint(0, 0), size) + image = QtGui.QImage(size, QtGui.QImage.Format.Format_ARGB32) + painter = _RecordingPainter(image) + try: + delegate.paint(painter, option, model.index(0)) + finally: + painter.end() + return painter.calls + + +class TestPaint: + def test_collapsed_row_folds_overflow_into_elided_last_line(self, delegate): + item = ListItem(text="\n".join(f"line {i}" for i in range(20)), height=ROW_H) + [(rect, _flags, text)] = _paint(delegate, item) + drawn = text.split("\n") + max_lines = max( + 1, int(rect.height()) // QtGui.QFontMetrics(QtGui.QFont()).lineSpacing() + ) + assert item.needs_expansion is True + assert len(drawn) == max_lines + assert drawn[:-1] == [f"line {i}" for i in range(max_lines - 1)] + assert drawn[-1].endswith("…") + + def test_right_text_clears_the_elided_main_text(self, delegate): + # Narrow glyphs elide flush to the rect edge, so only the gap separates them. + item = ListItem( + text="i" * 500, + right_text="123 KB", + height=ROW_H, + left_icon=QtGui.QPixmap(1, 1), + ) + [(rect, _flags, text), (x, _y, right)] = _paint(delegate, item) + fm = QtGui.QFontMetrics(QtGui.QFont()) + assert right == "123 KB" + assert text.endswith("…") + assert x - (rect.left() + fm.horizontalAdvance(text)) >= 5 + + def test_right_text_keeps_its_dev_position(self, delegate): + item = ListItem(text="A", right_text="123 KB", height=ROW_H) + [_main, (x, _y, _right)] = _paint(delegate, item) + rt_w = QtGui.QFontMetrics(QtGui.QFont()).horizontalAdvance("123 KB") + # Row inset 2, arrow slot 0.9 * height, 10px margin. + assert x == pytest.approx(ROW_W - 1 - 2 - 0.9 * ROW_H - rt_w - 10, abs=1) + + def test_text_size_hint_fits_is_painted_whole(self, delegate): + # Left font set, right font not: both must derive the right font the same way. + item = ListItem(text="", right_text="123 KB", _lfontsize=20, height=ROW_H) + model = EntryListModel([item]) + fits = "" + while True: + item.text = fits + "x" + delegate.sizeHint(_option(0), model.index(0)) + if item.needs_expansion: + break + fits += "x" + item.text = fits + [(_rect, _flags, painted), _right] = _paint(delegate, item) + assert painted == fits