diff --git a/BlocksScreen.cfg b/BlocksScreen.cfg index 065a900b..61f4c0fd 100644 --- a/BlocksScreen.cfg +++ b/BlocksScreen.cfg @@ -3,11 +3,15 @@ host: localhost port: 7125 [screensaver] -timeout: 5000 +timeout: 60000 [usb_manager] 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 +shown: False \ No newline at end of file diff --git a/BlocksScreen/lib/panels/utilitiesTab.py b/BlocksScreen/lib/panels/utilitiesTab.py index ecfd7419..84afa819 100644 --- a/BlocksScreen/lib/panels/utilitiesTab.py +++ b/BlocksScreen/lib/panels/utilitiesTab.py @@ -1,9 +1,11 @@ +import os +import signal import typing 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 +41,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 +71,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 +82,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 +123,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.""" + # 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.""" self.is_popup.show() @@ -198,16 +206,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 +280,7 @@ def _setup_ui(self) -> None: # Info - self.addWidget(self.info_page) + self.addWidget(self.config_page) # Leds Page @@ -295,7 +303,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/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() diff --git a/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py new file mode 100644 index 00000000..5f3f6c21 --- /dev/null +++ b/BlocksScreen/lib/panels/widgets/UtilitiesTab/configPage.py @@ -0,0 +1,366 @@ +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 + +_BOOL_WORDS = { + "true": ("true", "false"), + "false": ("true", "false"), + "yes": ("yes", "no"), + "no": ("yes", "no"), +} + +# Shown unless the section sets "shown: false"; any other section needs "shown: true" +_SHOWN_SECTIONS = frozenset( + { + "server", + } +) + +# Sections shown but not editable +_READ_ONLY_SECTIONS = frozenset({"server"}) +_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: + 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._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(): + 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(): + 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 != "shown"] + 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) + 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 + 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(self._to_display(section, option, value)) + if editable: + 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}", section, option, field) + ) + return field + + 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)) + 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 _field_value(self, section: str, option: str, field: BlocksField) -> str: + if (section, option) in self._bool_words: + on, off = self._bool_words[(section, option)] + 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 _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: + self._pending[key] = field + + 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._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") + + 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_header_layout.addSpacing(60) + + 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 ")) diff --git a/BlocksScreen/lib/utils/blocks_field.py b/BlocksScreen/lib/utils/blocks_field.py index 466b14d7..ab73c624 100644 --- a/BlocksScreen/lib/utils/blocks_field.py +++ b/BlocksScreen/lib/utils/blocks_field.py @@ -1,21 +1,24 @@ +import configparser import typing 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 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 +28,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 +44,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 +93,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 +113,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 = configparser.ConfigParser.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 +137,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 +161,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) 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, ) 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) 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 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()