From 800a3be0746902a43eb9f0a7edf658ab73eeee16 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Wed, 7 Oct 2026 10:15:24 +0100 Subject: [PATCH 01/13] fix: header layout --- .../lib/panels/widgets/Common/numpadPage.py | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/Common/numpadPage.py b/BlocksScreen/lib/panels/widgets/Common/numpadPage.py index dcd36f81..8b75d253 100644 --- a/BlocksScreen/lib/panels/widgets/Common/numpadPage.py +++ b/BlocksScreen/lib/panels/widgets/Common/numpadPage.py @@ -152,36 +152,24 @@ def _setup_ui(self) -> None: ) self.header_layout.addItem(spacerItem1) - self.numpad_title = BlocksLabel(self) - self.numpad_title.setMinimumSize(QtCore.QSize(500, 60)) - self.numpad_title.setMaximumSize(QtCore.QSize(16777215, 60)) font = QtGui.QFont() font.setPointSize(22) + + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + + self.numpad_title = QtWidgets.QLabel(self) + self.numpad_title.setMaximumSize(QtCore.QSize(16777215, 60)) self.numpad_title.setFont(font) - self.numpad_title.setAutoFillBackground(False) + self.numpad_title.setSizePolicy(sizePolicy) + self.numpad_title.setStyleSheet("background: transparent; color: white;") self.numpad_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - palette = QtGui.QPalette() - palette.setColor(palette.ColorRole.Window, QtGui.QColor("#FFFFFF00")) - palette.setColor(palette.ColorRole.WindowText, QtGui.QColor("#FFFFFF")) - self.numpad_title.setPalette(palette) self.numpad_title.setObjectName("numpad_title") - self.header_layout.addWidget( - self.numpad_title, - 0, - QtCore.Qt.AlignmentFlag.AlignHCenter | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) + self.header_layout.addWidget(self.numpad_title) self.numpad_back_btn = IconButton(self) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.MinimumExpanding, - ) - sizePolicy.setHorizontalStretch(1) - sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth( - self.numpad_back_btn.sizePolicy().hasHeightForWidth() - ) - self.numpad_back_btn.setSizePolicy(sizePolicy) self.numpad_back_btn.setMinimumSize(QtCore.QSize(60, 60)) self.numpad_back_btn.setMaximumSize(QtCore.QSize(60, 60)) self.numpad_back_btn.setPixmap(QtGui.QPixmap(":ui/media/btn_icons/back.svg")) @@ -191,10 +179,7 @@ def _setup_ui(self) -> None: 1, QtCore.Qt.AlignmentFlag.AlignVCenter, ) - self.numpad_back_btn.rect().setX(self.numpad_back_btn.rect().x() + 60) - self.header_layout.setStretch(0, 1) - self.header_layout.setStretch(1, 0) self.main_content_layout.addLayout(self.header_layout, 1) self.value_and_range_layout = QtWidgets.QVBoxLayout() From bf22a59fb7d97c72bc1625ad90c43b71f56e8e98 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Wed, 7 Oct 2026 10:17:27 +0100 Subject: [PATCH 02/13] add: added toggle button --- BlocksScreen/lib/utils/blocks_field.py | 57 +++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/BlocksScreen/lib/utils/blocks_field.py b/BlocksScreen/lib/utils/blocks_field.py index 466b14d7..dd886607 100644 --- a/BlocksScreen/lib/utils/blocks_field.py +++ b/BlocksScreen/lib/utils/blocks_field.py @@ -2,20 +2,33 @@ from lib.utils.blocks_combobox import BlocksComboBox from lib.utils.blocks_linedit import BlocksCustomLinEdit +from lib.utils.toggleAnimatedButton import ToggleAnimatedButton from PyQt6 import QtCore, QtGui, QtWidgets +BOOLEAN_STATES = { + "1": True, + "yes": True, + "true": True, + "on": True, + "0": False, + "no": False, + "false": False, + "off": False, +} + class BlocksField(QtWidgets.QWidget): - """Label row whose right side is a label, line edit or dropdown.""" + """Label row whose right side is a label, line edit, dropdown or toggle.""" on_edit = QtCore.pyqtSignal(name="on-edit") + clicked = QtCore.pyqtSignal(name="clicked") def __init__( self, parent: QtWidgets.QWidget | None = None, left_text: str = "", line: typing.Literal["bottom", "upper"] | None = None, - editable: typing.Literal["LineEdit", "DropDownMenu"] | None = None, + editable: typing.Literal["LineEdit", "DropDownMenu", "Toggle"] | None = None, ) -> None: super().__init__(parent) self.line = line @@ -25,8 +38,14 @@ def __init__( def _create_right_widget(self) -> QtWidgets.QWidget: if self.editable == "LineEdit": + font = QtGui.QFont() + font.setPointSize(13) lineedit = BlocksCustomLinEdit(self) lineedit.editingFinished.connect(self.on_edit) + lineedit.clicked.connect(self.clicked) + lineedit.setMinimumSize(QtCore.QSize(150, 50)) + lineedit.setFont(font) + lineedit.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) lineedit.textChanged.connect(self._sync_static_label) return lineedit if self.editable == "DropDownMenu": @@ -35,6 +54,12 @@ def _create_right_widget(self) -> QtWidgets.QWidget: combo.activated.connect(self.on_edit) combo.currentIndexChanged.connect(self._sync_static_label) return combo + if self.editable == "Toggle": + toggle = ToggleAnimatedButton(self) + toggle.setMinimumSize(QtCore.QSize(100, 50)) + toggle.stateChange.connect(self.on_edit) + toggle.stateChange.connect(self._sync_static_label) + return toggle return self._create_label() def _create_label(self) -> QtWidgets.QLabel: @@ -78,8 +103,8 @@ def _setup_field(self, left_text: str) -> None: sep = None if self.line is not None: sep = QtWidgets.QFrame(self) - sep.setFrameShape(QtWidgets.QFrame.Shape.HLine) - sep.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken) + sep.setFixedHeight(1) + sep.setStyleSheet("background-color: rgba(255, 255, 255, 50);") if sep and self.line == "upper": box.addWidget(sep) @@ -98,9 +123,13 @@ def set_left_text(self, text: str) -> None: self._left_lbl.setText(text) def set_right_text(self, text: str) -> None: - """Set the right text""" + """Set the right text; a Toggle takes config booleans like "true"/"off".""" if self.editable == "DropDownMenu": raise TypeError("set_right_text() is not supported for DropDownMenu") + if self.editable == "Toggle": + states = BOOLEAN_STATES + self.set_checked(states.get(text.strip().lower(), False)) + return self._right.setText(text) def set_right_stylesheet(self, stylesheet: str) -> None: @@ -118,8 +147,22 @@ def set_placeholder(self, text: str) -> None: self._require("DropDownMenu", "set_placeholder") self._right.setPlaceholderText(text) + def set_checked(self, checked: bool) -> None: + """Set the toggle state without emitting on_edit.""" + self._require("Toggle", "set_checked") + with QtCore.QSignalBlocker(self._right): + self._right.state = ToggleAnimatedButton.State(checked) + self._sync_static_label() + + def is_checked(self) -> bool: + """Return True when the toggle is on.""" + self._require("Toggle", "is_checked") + return self._right.state.value + def text(self) -> str: - """Return the current right-hand value.""" + """Return the current right-hand value; a Toggle gives "true"/"false".""" + if isinstance(self._right, ToggleAnimatedButton): + return "true" if self._right.state.value else "false" if isinstance(self._right, QtWidgets.QComboBox): return self._right.currentText() return self._right.text() @@ -128,6 +171,8 @@ def _sync_static_label(self) -> None: if self._static_lbl is None: return text = self.text() + if isinstance(self._right, ToggleAnimatedButton): + text = "On" if self._right.state.value else "Off" if not text and isinstance(self._right, QtWidgets.QComboBox): text = self._right.placeholderText() self._static_lbl.setText(text) From 1f2d83eaea76a481aa1eb3ed36da583f8c9f19ff Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Wed, 7 Oct 2026 10:22:20 +0100 Subject: [PATCH 03/13] fix: use widget aligment instead of harcoded --- BlocksScreen/lib/utils/blocks_linedit.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/BlocksScreen/lib/utils/blocks_linedit.py b/BlocksScreen/lib/utils/blocks_linedit.py index 61f4c089..dfd3c1a6 100644 --- a/BlocksScreen/lib/utils/blocks_linedit.py +++ b/BlocksScreen/lib/utils/blocks_linedit.py @@ -109,6 +109,9 @@ def _draw_text(self, painter: QtGui.QPainter) -> None: """Draw the text or placeholder.""" text_rect = self._get_text_rect() display_text = self.text() + align = ( + self.alignment() & QtCore.Qt.AlignmentFlag.AlignHorizontal_Mask + ) | QtCore.Qt.AlignmentFlag.AlignVCenter # Apply password masking if self._secret and display_text: @@ -119,8 +122,7 @@ def _draw_text(self, painter: QtGui.QPainter) -> None: painter.setFont(self.font()) painter.drawText( text_rect, - QtCore.Qt.AlignmentFlag.AlignLeft - | QtCore.Qt.AlignmentFlag.AlignVCenter, + align, display_text, ) else: @@ -129,7 +131,6 @@ def _draw_text(self, painter: QtGui.QPainter) -> None: painter.setFont(self.font()) painter.drawText( text_rect, - QtCore.Qt.AlignmentFlag.AlignLeft - | QtCore.Qt.AlignmentFlag.AlignVCenter, + align, self._placeholder_str, ) From 5fcfc2a6633f43ffdf13d11d413f674dc518086d Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Wed, 7 Oct 2026 11:36:56 +0100 Subject: [PATCH 04/13] add: added settings page --- BlocksScreen/lib/panels/utilitiesTab.py | 38 ++- .../panels/widgets/UtilitiesTab/configPage.py | 284 ++++++++++++++++++ .../panels/widgets/UtilitiesTab/infoPage.py | 123 -------- 3 files changed, 306 insertions(+), 139 deletions(-) create mode 100644 BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py delete mode 100644 BlocksScreen/lib/panels/widgets/UtilitiesTab/infoPage.py diff --git a/BlocksScreen/lib/panels/utilitiesTab.py b/BlocksScreen/lib/panels/utilitiesTab.py index ecfd7419..bb136456 100644 --- a/BlocksScreen/lib/panels/utilitiesTab.py +++ b/BlocksScreen/lib/panels/utilitiesTab.py @@ -3,7 +3,7 @@ from lib.moonrakerComm import MoonWebSocket from lib.panels.widgets.Common.basePopup import BasePopup from lib.panels.widgets.UtilitiesTab.axisMaintPage import AxisMaintPage -from lib.panels.widgets.UtilitiesTab.infoPage import InfoPage +from lib.panels.widgets.UtilitiesTab.configPage import ConfigPage from lib.panels.widgets.UtilitiesTab.inputshaperPage import InputShaperPage from lib.panels.widgets.UtilitiesTab.ledsPage import LedsPage from lib.panels.widgets.UtilitiesTab.routinePage import RoutineCheckPage @@ -39,7 +39,7 @@ def __init__( ) -> None: super().__init__(parent) - self.info_page: InfoPage = InfoPage(self) + self.config_page: ConfigPage = ConfigPage(self) self.leds_page: LedsPage = LedsPage(self) self.axes_page: AxisMaintPage = AxisMaintPage(self) self.routines_page: RoutineCheckPage = RoutineCheckPage(self) @@ -69,8 +69,8 @@ def __init__( self.up_axes_btn.clicked.connect( lambda: self.change_page(self.indexOf(self.axes_page)) ) - self.up_info_btn.clicked.connect( - lambda: self.change_page(self.indexOf(self.info_page)) + self.up_config_btn.clicked.connect( + lambda: self.change_page(self.indexOf(self.config_page)) ) self.up_routine_check_btn.clicked.connect( lambda: self.change_page(self.indexOf(self.routines_page)) @@ -80,7 +80,8 @@ def __init__( ) # --- Info --- - self.info_page.request_back_button.connect(self.request_back) + self.config_page.request_back_button.connect(self.request_back) + self.config_page.request_restart.connect(self.restart_screen) # --- LEDs --- self.leds_page.request_back_button.connect(self.request_back) @@ -120,6 +121,11 @@ def __init__( # --- Websocket/Printer Signals --- self.run_gcode_signal.connect(self.ws.api.run_gcode) + def restart_screen(self) -> None: + """Restart BlocksScreen so config changes read at startup take effect.""" + if not self.ws.api.restart_service("BlocksScreen"): + QtWidgets.QApplication.quit() + def ask_input_shaper(self) -> None: """Ask whether to run the automatic input shaper.""" self.is_popup.show() @@ -198,16 +204,16 @@ def _setup_ui(self) -> None: self.up_content_layout = QtWidgets.QGridLayout() self.up_content_layout.setObjectName("up_content_layout") - self.up_info_btn = BlocksCustomButton(parent=self.utilitiesPage) - self.up_info_btn.setSizePolicy(sizePolicy) - self.up_info_btn.setMinimumSize(QtCore.QSize(250, 80)) - self.up_info_btn.setMaximumSize(QtCore.QSize(250, 80)) - self.up_info_btn.setFont(font) - self.up_info_btn.setProperty( - "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/info.svg") + self.up_config_btn = BlocksCustomButton(parent=self.utilitiesPage) + self.up_config_btn.setSizePolicy(sizePolicy) + self.up_config_btn.setMinimumSize(QtCore.QSize(250, 80)) + self.up_config_btn.setMaximumSize(QtCore.QSize(250, 80)) + self.up_config_btn.setFont(font) + self.up_config_btn.setProperty( + "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/printer_settings.svg") ) - self.up_info_btn.setObjectName("up_info_btn") - self.up_content_layout.addWidget(self.up_info_btn, 0, 0, 1, 1) + self.up_config_btn.setObjectName("up_config_btn") + self.up_content_layout.addWidget(self.up_config_btn, 0, 0, 1, 1) self.up_leds_btn = BlocksCustomButton(parent=self.utilitiesPage) self.up_leds_btn.setSizePolicy(sizePolicy) @@ -272,7 +278,7 @@ def _setup_ui(self) -> None: # Info - self.addWidget(self.info_page) + self.addWidget(self.config_page) # Leds Page @@ -295,7 +301,7 @@ def _setup_ui(self) -> None: _translate = QtCore.QCoreApplication.translate self.up_title_label.setText(_translate("utilitiesTab", "Utilities")) - self.up_info_btn.setText(_translate("utilitiesTab", "Info")) + self.up_config_btn.setText(_translate("utilitiesTab", "Settings")) self.up_leds_btn.setText(_translate("utilitiesTab", "LED's")) self.up_routine_check_btn.setText(_translate("utilitiesTab", "Routine\nCheck")) self.up_axes_btn.setText(_translate("utilitiesTab", "Axis\nMaint.")) diff --git a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py new file mode 100644 index 00000000..fd4ad9fd --- /dev/null +++ b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py @@ -0,0 +1,284 @@ +import typing +from functools import partial + +from configfile import get_configparser +from lib.panels.widgets.Common.basePopup import BasePopup +from lib.panels.widgets.Common.keyboardPage import CustomQwertyKeyboard +from lib.panels.widgets.Common.numpadPage import CustomNumpad +from lib.utils.blocks_field import BlocksField +from lib.utils.blocks_frame import BlocksCustomFrame +from lib.utils.blocks_Scrollbar import CustomScrollBar +from lib.utils.icon_button import IconButton +from PyQt6 import QtCore, QtGui, QtWidgets + +# Boolean spellings shown as a toggle, saved back in the same style (on, off) +_BOOL_WORDS = { + "true": ("true", "false"), + "false": ("true", "false"), + "yes": ("yes", "no"), + "no": ("yes", "no"), +} + +_HIDDEN_ON = {on for on, _off in _BOOL_WORDS.values()} + +# TODO: find a better way to hide sections, hardcoded is kinda meh; +# a section with "hidden: true/yes" is hidden too, without hardcoding +_HIDDEN_SECTIONS = frozenset( + { + "filament_presence", + "usb_manager", + "hotspot", + "configuration_manager", + } +) + +# Sections shown but not editable +_READ_ONLY_SECTIONS = frozenset({"server"}) + + +def _pretty(name: str) -> str: + words = name.replace("_", " ").split() + text = " ".join(words) + return text[:1].upper() + text[1:] + + +class ConfigPage(QtWidgets.QWidget): + """Config page of the utilities tab, built from the BlocksScreen config file.""" + + request_back_button = QtCore.pyqtSignal(name="request-back-button") + request_restart = QtCore.pyqtSignal(name="request-restart") + + def __init__( + self, + parent: typing.Optional["QtWidgets.QWidget"], + ) -> None: + super().__init__(parent) + + self._setup_ui() + self._input_field: BlocksField | None = None + + self._numpad = CustomNumpad(self) + self._numpad.hide() + self._numpad_popup = BasePopup(self, False, False) + self._numpad_popup.add_widget(self._numpad) + self._numpad.request_back.connect(self._numpad_popup.hide) + self._numpad.value_selected.connect( + lambda _name, value: self._apply_input(str(value)) + ) + + self._qwerty = CustomQwertyKeyboard(self) + self._qwerty.hide() + self._qwerty.request_back.connect(self._qwerty.hide) + self._qwerty.value_selected.connect(self._on_qwerty_value_selected) + self.config_back_btn.clicked.connect(self.request_back_button.emit) + + self._restart_popup = BasePopup(self, floating=True) + self._restart_popup.confirm_button_text("Restart") + self._restart_popup.cancel_button_text("Later") + self._restart_popup.accepted.connect(self.request_restart.emit) + self.config_restart_btn.clicked.connect( + lambda: self._ask_restart("Restart the screen?\n\nMay take a few seconds.") + ) + + def setup_config_rows(self) -> None: + """Build one titled group per config section, one row per option.""" + self._cfg = get_configparser() + self._bool_words: dict[tuple[str, str], tuple[str, str]] = {} + column_rows = [0] * len(self._columns) + + for section, options in self._visible_sections(): + frame = self._build_section(section, options) + col = column_rows.index(min(column_rows)) + # +1 weighs in the group's title + column_rows[col] += len(options) + 1 + self._columns[col].addWidget(frame) + for column in self._columns: + column.addStretch(1) + + def _visible_sections(self) -> typing.Iterator[tuple[str, list[str]]]: + for section in self._cfg.sections(): + if section in _HIDDEN_SECTIONS: + continue + hidden = self._cfg.config.get(section, "hidden", fallback="") or "" + if hidden.strip().lower() in _HIDDEN_ON: + continue + options = [o for o in self._cfg.config.options(section) if o != "hidden"] + if options: + yield section, options + + def _build_section(self, section: str, options: list[str]) -> BlocksCustomFrame: + title = _pretty(section) + frame = BlocksCustomFrame(parent=self._rows_container) + frame.setProperty("text", title) + frame.setMinimumHeight(0) + frame_layout = QtWidgets.QVBoxLayout(frame) + frame_layout.setContentsMargins(16, 30, 16, 6) + frame_layout.setSpacing(0) + + last = len(options) - 1 + for i, option in enumerate(options): + field = self._build_row(frame, title, section, option, i < last) + frame_layout.addWidget(field) + return frame + + def _build_row( + self, + frame: BlocksCustomFrame, + title: str, + section: str, + option: str, + has_line: bool, + ) -> BlocksField: + value = self._cfg.config.get(section, option, fallback="") or "" + label = _pretty(option) + bool_words = _BOOL_WORDS.get(value.strip().lower()) + if section in _READ_ONLY_SECTIONS: + editable = None + elif bool_words: + editable = "Toggle" + self._bool_words[(section, option)] = bool_words + else: + editable = "LineEdit" + + field = BlocksField( + frame, f"{label}:", "bottom" if has_line else None, editable + ) + field.setFixedHeight(70) + field.set_right_text(value) + if editable: + field.on_edit.connect(partial(self._save_option, section, option, field)) + if editable == "LineEdit": + field.clicked.connect(partial(self._open_input, f"{title} {label}", field)) + return field + + def _open_input(self, name: str, field: BlocksField) -> None: + self._input_field = field + value = field.text().strip() + if value.isdigit(): + self._numpad.set_name(name) + self._numpad.set_value(int(value)) + self._numpad_popup.show() + self._numpad.set_enforce_range(False) + return + self._qwerty.set_value(value) + self._qwerty.show() + + def _on_qwerty_value_selected(self, value: str) -> None: + self._qwerty.hide() + self._apply_input(value) + + def _apply_input(self, value: str) -> None: + field, self._input_field = self._input_field, None + if field is None: + return + field.set_right_text(value) + field.on_edit.emit() + + def _save_option(self, section: str, option: str, field: BlocksField) -> None: + value = field.text() + if (section, option) in self._bool_words: + on, off = self._bool_words[(section, option)] + value = on if field.is_checked() else off + self._cfg.update_option(section, option, value) + self._cfg.save_configuration() + self._ask_restart("Setting saved.\n\nRestart the screen to apply it.") + + def _ask_restart(self, message: str) -> None: + if self._restart_popup.isVisible(): + return + self._restart_popup.set_message(message) + self._restart_popup.open() + + def _setup_ui(self) -> None: + self.setObjectName("config_page") + + spacerItem = QtWidgets.QSpacerItem( + 20, + 24, + QtWidgets.QSizePolicy.Policy.Minimum, + QtWidgets.QSizePolicy.Policy.Minimum, + ) + + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.verticalLayout_2.addItem(spacerItem) + + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum + ) + + font = QtGui.QFont() + font.setFamily("Momcake") + font.setPointSize(24) + + self.config_header_layout = QtWidgets.QHBoxLayout() + self.config_header_layout.setObjectName("config_header_layout") + + self.config_restart_btn = IconButton(parent=self) + self.config_restart_btn.setMinimumSize(QtCore.QSize(60, 60)) + self.config_restart_btn.setMaximumSize(QtCore.QSize(60, 60)) + self.config_restart_btn.setProperty( + "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/refresh.svg") + ) + self.config_header_layout.addWidget(self.config_restart_btn) + + self.config_title_label = QtWidgets.QLabel(parent=self) + self.config_title_label.setSizePolicy(sizePolicy) + self.config_title_label.setMaximumSize(QtCore.QSize(16777215, 60)) + self.config_title_label.setFont(font) + self.config_title_label.setStyleSheet("background: transparent; color: white;") + self.config_title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.config_header_layout.addWidget(self.config_title_label) + + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + QtWidgets.QSizePolicy.Policy.MinimumExpanding, + ) + + self.config_back_btn = IconButton(parent=self) + self.config_back_btn.setSizePolicy(sizePolicy) + self.config_back_btn.setMinimumSize(QtCore.QSize(60, 60)) + self.config_back_btn.setMaximumSize(QtCore.QSize(60, 60)) + self.config_back_btn.setProperty( + "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/back.svg") + ) + self.config_header_layout.addWidget(self.config_back_btn) + self.verticalLayout_2.addLayout(self.config_header_layout) + + self._scroll_area = QtWidgets.QScrollArea(self) + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + self._scroll_area.setStyleSheet( + "QScrollArea, QScrollArea > QWidget { background: transparent; }" + ) + self._scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self._scroll_area.setVerticalScrollBar(CustomScrollBar()) + + self._rows_container = QtWidgets.QWidget() + self._rows_container.setObjectName("config_rows") + self._rows_container.setStyleSheet("#config_rows { background: transparent; }") + rows_layout = QtWidgets.QHBoxLayout(self._rows_container) + rows_layout.setContentsMargins(12, 0, 12, 0) + rows_layout.setSpacing(10) + self._columns: list[QtWidgets.QVBoxLayout] = [] + for _ in range(1): + column = QtWidgets.QVBoxLayout() + column.setSpacing(10) + rows_layout.addLayout(column, 1) + self._columns.append(column) + self._scroll_area.setWidget(self._rows_container) + viewport = self._scroll_area.viewport() + QtWidgets.QScroller.grabGesture( + viewport, QtWidgets.QScroller.ScrollerGestureType.TouchGesture + ) + QtWidgets.QScroller.grabGesture( + viewport, QtWidgets.QScroller.ScrollerGestureType.LeftMouseButtonGesture + ) + self.setup_config_rows() + self.verticalLayout_2.addWidget(self._scroll_area) + + _translate = QtCore.QCoreApplication.translate + + self.config_title_label.setText(_translate("self", "Settings Page")) diff --git a/BlocksScreen/lib/panels/widgets/UtilitiesTab/infoPage.py b/BlocksScreen/lib/panels/widgets/UtilitiesTab/infoPage.py deleted file mode 100644 index 0f4ef130..00000000 --- a/BlocksScreen/lib/panels/widgets/UtilitiesTab/infoPage.py +++ /dev/null @@ -1,123 +0,0 @@ -import typing - -from lib.utils.blocks_frame import BlocksCustomFrame -from lib.utils.icon_button import IconButton -from PyQt6 import QtCore, QtGui, QtWidgets - - -class InfoPage(QtWidgets.QWidget): - """Info page of the utilities tab.""" - - request_back_button = QtCore.pyqtSignal(name="request-back-button") - - def __init__( - self, - parent: typing.Optional["QtWidgets.QWidget"], - ) -> None: - super().__init__(parent) - - self._setup_ui() - self.info_back_btn.clicked.connect(self.request_back_button.emit) - - def _setup_ui(self) -> None: - self.setObjectName("info_page") - - spacerItem = QtWidgets.QSpacerItem( - 20, - 24, - QtWidgets.QSizePolicy.Policy.Minimum, - QtWidgets.QSizePolicy.Policy.Minimum, - ) - - spacerItem1 = QtWidgets.QSpacerItem( - 60, - 60, - QtWidgets.QSizePolicy.Policy.Minimum, - QtWidgets.QSizePolicy.Policy.Minimum, - ) - - self.verticalLayout_2 = QtWidgets.QVBoxLayout(self) - self.verticalLayout_2.setObjectName("verticalLayout_2") - self.verticalLayout_2.addItem(spacerItem) - - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum - ) - - font = QtGui.QFont() - font.setFamily("Momcake") - font.setPointSize(24) - - self.info_header_layout = QtWidgets.QHBoxLayout() - self.info_header_layout.setObjectName("info_header_layout") - self.info_header_layout.addItem(spacerItem1) - - self.info_title_label = QtWidgets.QLabel(parent=self) - self.info_title_label.setSizePolicy(sizePolicy) - self.info_title_label.setMaximumSize(QtCore.QSize(16777215, 60)) - self.info_title_label.setFont(font) - self.info_title_label.setStyleSheet("background: transparent; color: white;") - self.info_title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_header_layout.addWidget(self.info_title_label) - - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.MinimumExpanding, - QtWidgets.QSizePolicy.Policy.MinimumExpanding, - ) - - self.info_back_btn = IconButton(parent=self) - self.info_back_btn.setSizePolicy(sizePolicy) - self.info_back_btn.setMinimumSize(QtCore.QSize(60, 60)) - self.info_back_btn.setMaximumSize(QtCore.QSize(60, 60)) - self.info_back_btn.setProperty( - "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/back.svg") - ) - self.info_header_layout.addWidget(self.info_back_btn) - self.verticalLayout_2.addLayout(self.info_header_layout) - - self.info_content_layout = QtWidgets.QVBoxLayout() - self.info_content_layout.setObjectName("info_content_layout") - - self.frame = BlocksCustomFrame(parent=self) - self.frame.setMinimumSize(QtCore.QSize(350, 260)) - self.frame.setMaximumSize(QtCore.QSize(350, 290)) - - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Policy.Preferred, - QtWidgets.QSizePolicy.Policy.Expanding, - ) - - font = QtGui.QFont() - font.setPointSize(20) - - self.kv_label = QtWidgets.QLabel(parent=self.frame) - self.kv_label.setGeometry(QtCore.QRect(0, 10, 351, 81)) - self.kv_label.setSizePolicy(sizePolicy) - self.kv_label.setFont(font) - self.kv_label.setStyleSheet("color:white") - self.kv_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.kv_label.setObjectName("kv_label") - - font = QtGui.QFont() - font.setPointSize(20) - - self.smth_label = QtWidgets.QLabel(parent=self.frame) - self.smth_label.setGeometry(QtCore.QRect(0, 110, 351, 81)) - self.smth_label.setSizePolicy(sizePolicy) - self.smth_label.setFont(font) - self.smth_label.setStyleSheet("color:white") - self.smth_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self.info_content_layout.addWidget( - self.frame, 0, QtCore.Qt.AlignmentFlag.AlignHCenter - ) - self.verticalLayout_2.addLayout(self.info_content_layout) - - _translate = QtCore.QCoreApplication.translate - - self.info_title_label.setText(_translate("self", "Info")) - self.info_title_label.setProperty("class", _translate("self", "title_text")) - self.info_back_btn.setText(_translate("self", "Back")) - self.info_back_btn.setProperty("class", _translate("self", "menu_btn")) - self.info_back_btn.setProperty("button_type", _translate("self", "icon")) - self.kv_label.setText(_translate("self", "Model: Blocks RF50")) - self.smth_label.setText(_translate("self", "www.blockstec.com ")) From 20b3709fc2e7a708afdda5597b97a7fc50f40105 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Thu, 8 Oct 2026 14:05:19 +0100 Subject: [PATCH 05/13] fix: config page saves on leave instead of on every edit --- .../panels/widgets/UtilitiesTab/configPage.py | 112 +++++++++++++----- 1 file changed, 83 insertions(+), 29 deletions(-) diff --git a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py index fd4ad9fd..c22cd24d 100644 --- a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py +++ b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py @@ -11,7 +11,11 @@ from lib.utils.icon_button import IconButton from PyQt6 import QtCore, QtGui, QtWidgets -# Boolean spellings shown as a toggle, saved back in the same style (on, off) +# TODO: find a better way to hide sections, hardcoded is kinda meh; +# a section with "hidden: true/yes" is hidden too, without hardcoding + +# TODO: screensaver is currenly hidden for numpad dosent have option for float (miliseconds to minutes works) + _BOOL_WORDS = { "true": ("true", "false"), "false": ("true", "false"), @@ -21,8 +25,7 @@ _HIDDEN_ON = {on for on, _off in _BOOL_WORDS.values()} -# TODO: find a better way to hide sections, hardcoded is kinda meh; -# a section with "hidden: true/yes" is hidden too, without hardcoding + _HIDDEN_SECTIONS = frozenset( { "filament_presence", @@ -34,6 +37,9 @@ # Sections shown but not editable _READ_ONLY_SECTIONS = frozenset({"server"}) +_DISPLAY_UNITS = { + ("screensaver", "timeout"): (60_000, "minutes"), +} def _pretty(name: str) -> str: @@ -72,18 +78,18 @@ def __init__( self._qwerty.value_selected.connect(self._on_qwerty_value_selected) self.config_back_btn.clicked.connect(self.request_back_button.emit) - self._restart_popup = BasePopup(self, floating=True) - self._restart_popup.confirm_button_text("Restart") - self._restart_popup.cancel_button_text("Later") - self._restart_popup.accepted.connect(self.request_restart.emit) - self.config_restart_btn.clicked.connect( - lambda: self._ask_restart("Restart the screen?\n\nMay take a few seconds.") - ) + self._leave_popup = BasePopup(self, floating=True) + self._leave_popup.confirm_button_text("Restart") + self._leave_popup.cancel_button_text("Discard") + self._leave_popup.accepted.connect(self._save_and_restart) + self._leave_popup.rejected.connect(self._discard_pending) def setup_config_rows(self) -> None: """Build one titled group per config section, one row per option.""" self._cfg = get_configparser() self._bool_words: dict[tuple[str, str], tuple[str, str]] = {} + self._saved: dict[tuple[str, str], str] = {} + self._pending: dict[tuple[str, str], BlocksField] = {} column_rows = [0] * len(self._columns) for section, options in self._visible_sections(): @@ -131,6 +137,9 @@ def _build_row( ) -> BlocksField: value = self._cfg.config.get(section, option, fallback="") or "" label = _pretty(option) + unit = _DISPLAY_UNITS.get((section, option)) + if unit: + label = f"{label} ({unit[1]})" bool_words = _BOOL_WORDS.get(value.strip().lower()) if section in _READ_ONLY_SECTIONS: editable = None @@ -144,9 +153,10 @@ def _build_row( frame, f"{label}:", "bottom" if has_line else None, editable ) field.setFixedHeight(70) - field.set_right_text(value) + field.set_right_text(self._to_display(section, option, value)) if editable: - field.on_edit.connect(partial(self._save_option, section, option, field)) + self._saved[(section, option)] = self._field_value(section, option, field) + field.on_edit.connect(partial(self._stage_option, section, option, field)) if editable == "LineEdit": field.clicked.connect(partial(self._open_input, f"{title} {label}", field)) return field @@ -174,20 +184,70 @@ def _apply_input(self, value: str) -> None: field.set_right_text(value) field.on_edit.emit() - def _save_option(self, section: str, option: str, field: BlocksField) -> None: - value = field.text() + def _field_value(self, section: str, option: str, field: BlocksField) -> str: if (section, option) in self._bool_words: on, off = self._bool_words[(section, option)] - value = on if field.is_checked() else off - self._cfg.update_option(section, option, value) - self._cfg.save_configuration() - self._ask_restart("Setting saved.\n\nRestart the screen to apply it.") + return on if field.is_checked() else off + return self._to_stored(section, option, field.text()) + + def _to_display(self, section: str, option: str, value: str) -> str: + unit = _DISPLAY_UNITS.get((section, option)) + if not unit: + return value + try: + shown = int(value) / unit[0] + except ValueError: + return value + return f"{round(shown, 2):g}" + + def _to_stored(self, section: str, option: str, text: str) -> str: + unit = _DISPLAY_UNITS.get((section, option)) + if not unit: + return text + try: + return str(round(float(text) * unit[0])) + except ValueError: + return text + + def _stage_option(self, section: str, option: str, field: BlocksField) -> None: + key = (section, option) + if self._field_value(section, option, field) == self._saved[key]: + self._pending.pop(key, None) + else: + self._pending[key] = field - def _ask_restart(self, message: str) -> None: - if self._restart_popup.isVisible(): + def _save_pending(self) -> None: + if not self._pending: + return + for (section, option), field in self._pending.items(): + value = self._field_value(section, option, field) + self._cfg.update_option(section, option, value) + self._saved[(section, option)] = value + self._cfg.save_configuration() + self._pending.clear() + + def _discard_pending(self) -> None: + for (section, option), field in self._pending.items(): + saved = self._saved[(section, option)] + field.set_right_text(self._to_display(section, option, saved)) + self._pending.clear() + + def _save_and_restart(self) -> None: + self._save_pending() + self.request_restart.emit() + + def hideEvent(self, a0: QtGui.QHideEvent | None) -> None: + """Ask to apply or discard pending edits when the page is left.""" + super().hideEvent(a0) + if a0 is not None and a0.spontaneous(): + return + if not self._pending or self._leave_popup.isVisible(): return - self._restart_popup.set_message(message) - self._restart_popup.open() + self._leave_popup.set_message( + f"{len(self._pending)} unsaved change(s).\n\n" + "Restart the screen to apply them?" + ) + self._leave_popup.open() def _setup_ui(self) -> None: self.setObjectName("config_page") @@ -214,13 +274,7 @@ def _setup_ui(self) -> None: self.config_header_layout = QtWidgets.QHBoxLayout() self.config_header_layout.setObjectName("config_header_layout") - self.config_restart_btn = IconButton(parent=self) - self.config_restart_btn.setMinimumSize(QtCore.QSize(60, 60)) - self.config_restart_btn.setMaximumSize(QtCore.QSize(60, 60)) - self.config_restart_btn.setProperty( - "icon_pixmap", QtGui.QPixmap(":/ui/media/btn_icons/refresh.svg") - ) - self.config_header_layout.addWidget(self.config_restart_btn) + self.config_header_layout.addSpacing(60) self.config_title_label = QtWidgets.QLabel(parent=self) self.config_title_label.setSizePolicy(sizePolicy) From ef4b3593e18cd8e7051d2243589cef8663ceb29e Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Thu, 8 Oct 2026 14:10:45 +0100 Subject: [PATCH 06/13] upd: updated based on setings page --- BlocksScreen.cfg | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/BlocksScreen.cfg b/BlocksScreen.cfg index 065a900b..ec9c5110 100644 --- a/BlocksScreen.cfg +++ b/BlocksScreen.cfg @@ -3,6 +3,7 @@ host: localhost port: 7125 [screensaver] +hidden: True timeout: 5000 [usb_manager] @@ -10,4 +11,8 @@ gcodes_dir: ~/printer_data/gcodes/ [filament_presence] object: cutter_sensor -name: extruder_cutter \ No newline at end of file +name: extruder_cutter + +[amu] +toggle: False +hidden: True \ No newline at end of file From 35d67f8b86bfadab5a92d9a123cc3737f5b6414d Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 12:18:53 +0100 Subject: [PATCH 07/13] bugfix: added minimmun timeout --- BlocksScreen/screensaver.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/BlocksScreen/screensaver.py b/BlocksScreen/screensaver.py index 20cda0cf..6a5e4e80 100644 --- a/BlocksScreen/screensaver.py +++ b/BlocksScreen/screensaver.py @@ -1,6 +1,8 @@ import helper_methods as helper_methods from PyQt6 import QtCore, QtWidgets +MIN_TIMEOUT_MS = 60_000 + class ScreenSaver(QtCore.QObject): """Screensaver that uses X11 DPMS to blank the display after inactivity.""" @@ -27,9 +29,12 @@ def __init__(self, parent) -> None: self.dpms_standby_timeout if self.dpms_standby_timeout else 900000 ) else: - self.blank_timeout = self.screensaver_config.getint( - "timeout", default=500000 - ) + try: + timeout = self.screensaver_config.getint("timeout", default=500000) + except ValueError: + timeout = 500000 + # 0 fires at once and keeps the screen blank, so enforce a floor + self.blank_timeout = max(timeout, MIN_TIMEOUT_MS) QtWidgets.QApplication.instance().installEventFilter(self) self.timer.timeout.connect(self.check_dpms) self.timer.setInterval(self.blank_timeout) From e650f3089bc88ffdbe24f63dc1ea93fc9b1ffb44 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 12:29:10 +0100 Subject: [PATCH 08/13] fix: adress pr review --- BlocksScreen.cfg | 4 +- BlocksScreen/lib/panels/utilitiesTab.py | 4 +- .../panels/widgets/UtilitiesTab/configPage.py | 68 +++++++++++++------ 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/BlocksScreen.cfg b/BlocksScreen.cfg index ec9c5110..965d9169 100644 --- a/BlocksScreen.cfg +++ b/BlocksScreen.cfg @@ -3,7 +3,7 @@ host: localhost port: 7125 [screensaver] -hidden: True +shown: False timeout: 5000 [usb_manager] @@ -15,4 +15,4 @@ name: extruder_cutter [amu] toggle: False -hidden: True \ No newline at end of file +shown: False \ No newline at end of file diff --git a/BlocksScreen/lib/panels/utilitiesTab.py b/BlocksScreen/lib/panels/utilitiesTab.py index bb136456..0c2754ca 100644 --- a/BlocksScreen/lib/panels/utilitiesTab.py +++ b/BlocksScreen/lib/panels/utilitiesTab.py @@ -123,8 +123,8 @@ def __init__( def restart_screen(self) -> None: """Restart BlocksScreen so config changes read at startup take effect.""" - if not self.ws.api.restart_service("BlocksScreen"): - QtWidgets.QApplication.quit() + QtWidgets.QApplication.quit() + # uses service auto start to start the app def ask_input_shaper(self) -> None: """Ask whether to run the automatic input shaper.""" diff --git a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py index c22cd24d..5f3f6c21 100644 --- a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py +++ b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py @@ -11,11 +11,6 @@ from lib.utils.icon_button import IconButton from PyQt6 import QtCore, QtGui, QtWidgets -# TODO: find a better way to hide sections, hardcoded is kinda meh; -# a section with "hidden: true/yes" is hidden too, without hardcoding - -# TODO: screensaver is currenly hidden for numpad dosent have option for float (miliseconds to minutes works) - _BOOL_WORDS = { "true": ("true", "false"), "false": ("true", "false"), @@ -23,15 +18,10 @@ "no": ("yes", "no"), } -_HIDDEN_ON = {on for on, _off in _BOOL_WORDS.values()} - - -_HIDDEN_SECTIONS = frozenset( +# Shown unless the section sets "shown: false"; any other section needs "shown: true" +_SHOWN_SECTIONS = frozenset( { - "filament_presence", - "usb_manager", - "hotspot", - "configuration_manager", + "server", } ) @@ -40,6 +30,10 @@ _DISPLAY_UNITS = { ("screensaver", "timeout"): (60_000, "minutes"), } +# Whole-number range in display units; a 0 minute timeout keeps the screen blank +_LIMITS = { + ("screensaver", "timeout"): (1, 120), +} def _pretty(name: str) -> str: @@ -103,12 +97,14 @@ def setup_config_rows(self) -> None: def _visible_sections(self) -> typing.Iterator[tuple[str, list[str]]]: for section in self._cfg.sections(): - if section in _HIDDEN_SECTIONS: - continue - hidden = self._cfg.config.get(section, "hidden", fallback="") or "" - if hidden.strip().lower() in _HIDDEN_ON: + default = section in _SHOWN_SECTIONS + try: + shown = self._cfg.config.getboolean(section, "shown", fallback=default) + except ValueError: + shown = default + if not shown: continue - options = [o for o in self._cfg.config.options(section) if o != "hidden"] + options = [o for o in self._cfg.config.options(section) if o != "shown"] if options: yield section, options @@ -158,12 +154,30 @@ def _build_row( self._saved[(section, option)] = self._field_value(section, option, field) field.on_edit.connect(partial(self._stage_option, section, option, field)) if editable == "LineEdit": - field.clicked.connect(partial(self._open_input, f"{title} {label}", field)) + field.clicked.connect( + partial(self._open_input, f"{title} {label}", section, option, field) + ) return field - def _open_input(self, name: str, field: BlocksField) -> None: + def _open_input( + self, name: str, section: str, option: str, field: BlocksField + ) -> None: self._input_field = field value = field.text().strip() + limits = _LIMITS.get((section, option)) + if limits: + low, high = limits + try: + start = min(max(round(float(value)), low), high) + except ValueError: + start = low + self._numpad.set_name(name) + self._numpad.set_min_value(low) + self._numpad.set_max_value(high) + self._numpad.set_enforce_range(True) + self._numpad.set_value(start) + self._numpad_popup.show() + return if value.isdigit(): self._numpad.set_name(name) self._numpad.set_value(int(value)) @@ -209,8 +223,22 @@ def _to_stored(self, section: str, option: str, text: str) -> str: except ValueError: return text + def _is_valid(self, section: str, option: str, text: str) -> bool: + limits = _LIMITS.get((section, option)) + if not limits: + return True + try: + value = int(text.strip()) + except ValueError: + return False + return limits[0] <= value <= limits[1] + def _stage_option(self, section: str, option: str, field: BlocksField) -> None: key = (section, option) + if not self._is_valid(section, option, field.text()): + field.set_right_text(self._to_display(section, option, self._saved[key])) + self._pending.pop(key, None) + return if self._field_value(section, option, field) == self._saved[key]: self._pending.pop(key, None) else: From 5cf730ff977b1aebad610d6a035b2dcb51c4c90c Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 15:52:00 +0100 Subject: [PATCH 09/13] add: added tests --- tests/widgets/test_blocks_field_unit.py | 27 +++++++ tests/widgets/test_config_page_unit.py | 97 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/widgets/test_blocks_field_unit.py create mode 100644 tests/widgets/test_config_page_unit.py diff --git a/tests/widgets/test_blocks_field_unit.py b/tests/widgets/test_blocks_field_unit.py new file mode 100644 index 00000000..9d738e1c --- /dev/null +++ b/tests/widgets/test_blocks_field_unit.py @@ -0,0 +1,27 @@ +"""Unit tests for BlocksField Toggle mode.""" + +import pytest + +from BlocksScreen.lib.utils.blocks_field import BlocksField + + +@pytest.fixture +def toggle(qtbot): + field = BlocksField(None, "Enabled:", editable="Toggle") + qtbot.addWidget(field) + return field + + +def test_set_checked_does_not_emit_on_edit(toggle, qtbot): + with qtbot.assertNotEmitted(toggle.on_edit): + toggle.set_checked(True) + toggle.set_right_text("off") + assert not toggle.is_checked() + + +@pytest.mark.parametrize( + ("text", "checked"), [("yes", True), ("On", True), ("1", True), ("no", False)] +) +def test_set_right_text_reads_config_booleans(toggle, text, checked): + toggle.set_right_text(text) + assert toggle.is_checked() is checked diff --git a/tests/widgets/test_config_page_unit.py b/tests/widgets/test_config_page_unit.py new file mode 100644 index 00000000..085aedc6 --- /dev/null +++ b/tests/widgets/test_config_page_unit.py @@ -0,0 +1,97 @@ +"""Unit tests for ConfigPage section visibility and timeout validation.""" + +import importlib.util +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from PyQt6 import QtCore, QtWidgets + +# conftest mocks "configfile", so load the real one under another name +_spec = importlib.util.spec_from_file_location( + "_real_configfile", + Path(__file__).resolve().parents[2] / "BlocksScreen" / "configfile.py", +) +_configfile = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_configfile) + +_SERVER = "[server]\nhost: localhost\n" +_KEY = ("screensaver", "timeout") + + +class _FakeInput(QtWidgets.QWidget): + request_back = QtCore.pyqtSignal() + value_selected = QtCore.pyqtSignal(str) + + def __getattr__(self, name): + return MagicMock() + + +@pytest.fixture +def make_page(qtbot, monkeypatch, tmp_path): + from BlocksScreen.lib.panels.widgets.UtilitiesTab import configPage + + def make(text): + path = tmp_path / "BlocksScreen.cfg" + path.write_text(text) + cfg = _configfile.BlocksScreenConfig(path, "server") + cfg.load_config() + monkeypatch.setattr(configPage, "get_configparser", lambda: cfg) + monkeypatch.setattr(configPage, "CustomNumpad", _FakeInput) + monkeypatch.setattr(configPage, "CustomQwertyKeyboard", _FakeInput) + page = configPage.ConfigPage(None) + qtbot.addWidget(page) + return page, path + + return make + + +def _sections(page): + return [section for section, _ in page._visible_sections()] + + +def test_only_server_shown_by_default(make_page): + page, _ = make_page(_SERVER + "[screensaver]\ntimeout: 300000\n") + assert _sections(page) == ["server"] + + +@pytest.mark.parametrize("value", ["true", "yes", "on", "1"]) +def test_shown_true_shows_section(make_page, value): + page, _ = make_page(_SERVER + f"[screensaver]\nshown: {value}\ntimeout: 1\n") + assert _sections(page) == ["server", "screensaver"] + + +def test_shown_false_hides_server(make_page): + page, _ = make_page("[server]\nshown: false\nhost: localhost\n") + assert _sections(page) == [] + + +def test_invalid_shown_uses_default(make_page): + page, _ = make_page(_SERVER + "[screensaver]\nshown: maybe\ntimeout: 1\n") + assert _sections(page) == ["server"] + + +@pytest.mark.parametrize( + ("text", "valid"), + [("1", True), ("120", True), ("0", False), ("121", False), ("abc", False)], +) +def test_timeout_validation(make_page, text, valid): + page, _ = make_page(_SERVER) + assert page._is_valid(*_KEY, text) is valid + + +def test_invalid_timeout_reverts_and_valid_one_saves(make_page): + from BlocksScreen.lib.panels.widgets.UtilitiesTab.configPage import BlocksField + + page, path = make_page(_SERVER + "[screensaver]\nshown: true\ntimeout: 300000\n") + field = next(f for f in page.findChildren(BlocksField) if f.text() == "5") + + field.set_right_text("0") + page._stage_option(*_KEY, field) + assert field.text() == "5" + assert not page._pending + + field.set_right_text("10") + page._stage_option(*_KEY, field) + page._save_pending() + assert "timeout: 600000" in path.read_text() From 5bb08d2d4d226305821a3774697830671d79a679 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 15:52:31 +0100 Subject: [PATCH 10/13] fix: updated sections and values --- BlocksScreen.cfg | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/BlocksScreen.cfg b/BlocksScreen.cfg index 965d9169..61f4c0fd 100644 --- a/BlocksScreen.cfg +++ b/BlocksScreen.cfg @@ -3,15 +3,14 @@ host: localhost port: 7125 [screensaver] -shown: False -timeout: 5000 +timeout: 60000 [usb_manager] gcodes_dir: ~/printer_data/gcodes/ [filament_presence] object: cutter_sensor -name: extruder_cutter +name: extruder_cutter [amu] toggle: False From 088e1af2a1fece3ceec609f1ff0abdc949713c7a Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 15:58:05 +0100 Subject: [PATCH 11/13] upd: updated restart screen --- BlocksScreen/lib/panels/utilitiesTab.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/BlocksScreen/lib/panels/utilitiesTab.py b/BlocksScreen/lib/panels/utilitiesTab.py index 0c2754ca..84afa819 100644 --- a/BlocksScreen/lib/panels/utilitiesTab.py +++ b/BlocksScreen/lib/panels/utilitiesTab.py @@ -1,3 +1,5 @@ +import os +import signal import typing from lib.moonrakerComm import MoonWebSocket @@ -123,8 +125,8 @@ def __init__( def restart_screen(self) -> None: """Restart BlocksScreen so config changes read at startup take effect.""" - QtWidgets.QApplication.quit() - # uses service auto start to start the app + # SIGTERM runs the normal shutdown (restart splash); the service starts it again + os.kill(os.getpid(), signal.SIGTERM) def ask_input_shaper(self) -> None: """Ask whether to run the automatic input shaper.""" From 9051fdc714e534472b58e7c7e8cc5ce91ddeeac4 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 15:59:00 +0100 Subject: [PATCH 12/13] fix: use configparser --- BlocksScreen/lib/utils/blocks_field.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/BlocksScreen/lib/utils/blocks_field.py b/BlocksScreen/lib/utils/blocks_field.py index dd886607..ab73c624 100644 --- a/BlocksScreen/lib/utils/blocks_field.py +++ b/BlocksScreen/lib/utils/blocks_field.py @@ -1,3 +1,4 @@ +import configparser import typing from lib.utils.blocks_combobox import BlocksComboBox @@ -5,17 +6,6 @@ from lib.utils.toggleAnimatedButton import ToggleAnimatedButton from PyQt6 import QtCore, QtGui, QtWidgets -BOOLEAN_STATES = { - "1": True, - "yes": True, - "true": True, - "on": True, - "0": False, - "no": False, - "false": False, - "off": False, -} - class BlocksField(QtWidgets.QWidget): """Label row whose right side is a label, line edit, dropdown or toggle.""" @@ -127,7 +117,7 @@ def set_right_text(self, text: str) -> None: if self.editable == "DropDownMenu": raise TypeError("set_right_text() is not supported for DropDownMenu") if self.editable == "Toggle": - states = BOOLEAN_STATES + states = configparser.ConfigParser.BOOLEAN_STATES self.set_checked(states.get(text.strip().lower(), False)) return self._right.setText(text) From f706114feb1193b4c727e653b93e1681ad49c664 Mon Sep 17 00:00:00 2001 From: RobeMartins Date: Tue, 13 Oct 2026 17:23:37 +0100 Subject: [PATCH 13/13] add: add screen saver test --- tests/util/test_screensaver_unit.py | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/util/test_screensaver_unit.py diff --git a/tests/util/test_screensaver_unit.py b/tests/util/test_screensaver_unit.py new file mode 100644 index 00000000..68f7f27d --- /dev/null +++ b/tests/util/test_screensaver_unit.py @@ -0,0 +1,49 @@ +"""Unit tests for the screensaver timeout floor and fallback.""" + +import importlib +import pathlib +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +_BLOCKSSCREEN = pathlib.Path(__file__).parent.parent.parent / "BlocksScreen" +if str(_BLOCKSSCREEN) not in sys.path: + sys.path.append(str(_BLOCKSSCREEN)) + +# drop tests/network's configfile mock +sys.modules.pop("configfile", None) + +from configfile import BlocksScreenConfig # noqa: E402 + + +@pytest.fixture +def make_saver(qapp, tmp_path, monkeypatch): + # tests/panels stubs "screensaver"; import the real one for this test only + monkeypatch.delitem(sys.modules, "screensaver", raising=False) + screensaver = importlib.import_module("screensaver") + monkeypatch.setattr(screensaver.ScreenSaver, "_dpms_available", False) + # keep the shared class-level QTimer from being started + monkeypatch.setattr(screensaver.ScreenSaver, "timer", MagicMock()) + + def make(timeout): + path = tmp_path / "BlocksScreen.cfg" + path.write_text( + f"[server]\nhost: localhost\n\n[screensaver]\ntimeout: {timeout}\n" + ) + cfg = BlocksScreenConfig(path, "server") + cfg.load_config() + saver = screensaver.ScreenSaver(SimpleNamespace(config=cfg)) + qapp.removeEventFilter(saver) + return saver + + return make + + +@pytest.mark.parametrize( + ("timeout", "expected"), + [("0", 60_000), ("5000", 60_000), ("300000", 300_000), ("abc", 500_000)], +) +def test_blank_timeout(make_saver, timeout, expected): + assert make_saver(timeout).blank_timeout == expected