diff --git a/BlocksScreen/lib/panels/networkWindow.py b/BlocksScreen/lib/panels/networkWindow.py index ea16b4d8..09e2649f 100644 --- a/BlocksScreen/lib/panels/networkWindow.py +++ b/BlocksScreen/lib/panels/networkWindow.py @@ -3721,7 +3721,9 @@ def _setup_keyboard(self) -> None: (self.wifi_sip_dns2_field, self.wifi_static_ip_page), ]: field.clicked.connect( - lambda _=False, f=field, p=page: self._on_show_keyboard(p, f) + lambda _=False, f=field, p=page: self._on_show_keyboard( + p, f, numeric=True + ) ) def _setup_scrollbar_signals(self) -> None: @@ -3764,11 +3766,16 @@ def _configure_list_view_palette(self) -> None: self.listView.setPalette(palette) def _on_show_keyboard( - self, panel: QtWidgets.QWidget, field: QtWidgets.QLineEdit + self, + panel: QtWidgets.QWidget, + field: QtWidgets.QLineEdit, + numeric: bool = False, ) -> None: """Show the QWERTY keyboard panel, saving the originating panel and input field.""" self._previous_panel = panel self._current_field = field + self._qwerty.setPattern("ip" if numeric else "") + self._qwerty.setNumericOnly(numeric) self._qwerty.set_value(field.text()) self._qwerty.show() field.clearFocus() diff --git a/BlocksScreen/lib/panels/widgets/keyboardPage.py b/BlocksScreen/lib/panels/widgets/keyboardPage.py index efddaa93..64e79123 100644 --- a/BlocksScreen/lib/panels/widgets/keyboardPage.py +++ b/BlocksScreen/lib/panels/widgets/keyboardPage.py @@ -1,6 +1,9 @@ +"""On-screen keyboards: full QWERTY and a numeric variant for IP and mask entry.""" + import typing from lib.utils.icon_button import IconButton +from lib.utils.numpad_button import NumpadButton from PyQt6 import QtCore, QtGui, QtWidgets _LOWERCASE = list("qwertyuiopasdfghjklzxcvbnm") @@ -72,6 +75,30 @@ def _make_key_font(size: int = 29) -> QtGui.QFont: return font +def _valid_ip(value: str) -> bool: + # Partial entry: empty octets are still being typed. + parts = value.split(".") + return len(parts) <= 4 and all(p.isdigit() and int(p) <= 255 for p in parts if p) + + +def _valid_float(value: str) -> bool: + if not value: + return True + try: + float(value) + except ValueError: + return value.endswith(".") + return True + + +_PATTERN_VALIDATORS = { + "ip": _valid_ip, + "hex": lambda v: all(c in "0123456789abcdefABCDEF" for c in v), + "int": lambda v: v == "" or v.lstrip("-").isdigit(), + "float": _valid_float, +} + + class CustomQwertyKeyboard(QtWidgets.QDialog): """Custom on-screen QWERTY keyboard for touch input.""" @@ -89,8 +116,11 @@ def __init__(self, parent: QtWidgets.QWidget) -> None: self.suffix: str = "" self.symbolsrun: bool = False self._key_buttons: list[QtWidgets.QPushButton] = [] + self._row_widgets: list[QtWidgets.QWidget] = [] + self._numpad_digits: list[QtWidgets.QPushButton] = [] self._pattern: str = "" self._max_length: int = 0 + self._numeric_only: bool = False self.firsttime: bool = True self._setup_ui() @@ -102,6 +132,13 @@ def __init__(self, parent: QtWidgets.QWidget) -> None: for btn in self._key_buttons: btn.clicked.connect(lambda _, b=btn: self.value_inserted(b.text())) + for btn in self._numpad_digits: + btn.clicked.connect(lambda _, b=btn: self.value_inserted(b.text())) + + self.np_dot.clicked.connect(lambda: self.value_inserted(".")) + self.np_delete.clicked.connect(lambda: self.value_inserted("clear")) + self.np_enter.clicked.connect(lambda: self.value_inserted("enter")) + self.K_dot.clicked.connect(lambda: self.value_inserted(".")) self.K_space.clicked.connect(lambda: self.value_inserted(" ")) self.k_Enter.clicked.connect(lambda: self.value_inserted("enter")) @@ -132,6 +169,9 @@ def __init__(self, parent: QtWidgets.QWidget) -> None: " background-color: #212120;" " color: white;" "}" + 'QPushButton[numpad_key="true"] {' + " font-family: 'Momcake-Bold';" + "}" ) self.handle_keyboard_layout() @@ -147,10 +187,32 @@ def setPattern(self, pattern: str) -> None: """Set input validation pattern: 'ip', 'hex', 'int', 'float', or '' for no pattern.""" self._pattern = pattern + def setNumericOnly(self, enabled: bool) -> None: + """Swap the QWERTY rows for a full-size numpad on IP, mask, gateway and DNS fields.""" + if self._numeric_only == enabled: + return + self._numeric_only = enabled + for widget in self._row_widgets: + widget.setVisible(not enabled) + for btn in ( + self.K_shift, + self.K_keychange, + self.K_space, + self.K_dot, + self.k_delete, + self.k_Enter, + ): + btn.setVisible(not enabled) + self._numpad_widget.setVisible(enabled) + if enabled: + self.K_shift.setChecked(False) + self.K_keychange.setChecked(False) + self.symbolsrun = False + self.handle_keyboard_layout() + def setMaxLength(self, length: int) -> None: """Set maximum allowed length for user input (excluding prefix/suffix).""" - if length < 0: - length = 0 + length = max(length, 0) if length == 0: length = 999 self._max_length = length @@ -162,29 +224,9 @@ def _flash_limit_warning(self) -> None: ) def _validate_pattern(self, value: str) -> bool: - if not self._pattern: - return True - if self._pattern == "ip": - parts = value.split(".") - if len(parts) > 4: - return False - for part in parts: - if part and (not part.isdigit() or int(part) > 255): - return False - return True - if self._pattern == "hex": - return all(c in "0123456789abcdefABCDEF" for c in value) - if self._pattern == "int": - return value == "" or value.lstrip("-").isdigit() - if self._pattern == "float": - if not value: - return True - try: - float(value) - return True - except ValueError: - return value.endswith(".") - return True + """Return True if value is an acceptable partial entry for the active pattern.""" + validator = _PATTERN_VALIDATORS.get(self._pattern or "") + return validator(value) if validator else True def _get_mainWindow_widget(self) -> QtWidgets.QMainWindow | None: """Get the main application window""" @@ -213,10 +255,12 @@ def _geometry_calc(self) -> None: self.setGeometry(x, y, width, height) def showEvent(self, a0: QtGui.QShowEvent | None) -> None: + """Re-implemented method, recompute layout geometry before showing.""" self._geometry_calc() self.firsttime = True return super().showEvent(a0) + @QtCore.pyqtSlot() def handle_keyboard_layout(self) -> None: """Update key labels based on current shift/keychange state.""" shift = self.K_shift.isChecked() @@ -239,7 +283,7 @@ def handle_keyboard_layout(self) -> None: else: layout = _LOWERCASE - for btn, txt in zip(self._key_buttons, layout): + for btn, txt in zip(self._key_buttons, layout, strict=False): btn.setText(txt) self.K_shift.setText("#+=") if keychange else self.K_shift.setText("⇧") @@ -247,9 +291,11 @@ def handle_keyboard_layout(self) -> None: def value_inserted(self, value: str) -> None: """Handle key press: append char, delete, or submit on enter.""" - if self.firsttime and value not in ("enter", "clear"): - self.current_value = "" + if self.firsttime: self.firsttime = False + # Only a typed key replaces the prefill; delete edits it in place. + if value not in ("enter", "clear"): + self.current_value = "" if value == "&&": value = "&" @@ -262,6 +308,7 @@ def value_inserted(self, value: str) -> None: self.setSuffix("") self.setPattern("") self.setMaxLength(0) + self.setNumericOnly(False) return if value == "clear": @@ -322,6 +369,71 @@ def _create_key_button( btn.setObjectName(name) return btn + def _create_numpad_button(self, text: str, name: str) -> NumpadButton: + """Create a pill key matching the CustomNumpad look.""" + btn = NumpadButton(self._numpad_widget) + btn.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed + ) + btn.setMinimumSize(QtCore.QSize(150, 60)) + btn.setLayoutDirection(QtCore.Qt.LayoutDirection.RightToLeft) + btn.setFlat(True) + btn.setText(text) + btn.setProperty("numpad_key", True) + btn.setObjectName(name) + return btn + + def _create_numpad_icon(self, name: str, pixmap: str) -> IconButton: + """Create a 60x60 icon key for the numpad enter/clear actions.""" + btn = IconButton(parent=self._numpad_widget) + btn.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed + ) + btn.setMinimumSize(QtCore.QSize(60, 60)) + btn.setMaximumSize(QtCore.QSize(60, 60)) + btn.setFlat(True) + btn.setProperty("icon_pixmap", QtGui.QPixmap(pixmap)) + btn.setProperty("button_type", "icon") + btn.setObjectName(name) + return btn + + def _setup_numpad(self) -> None: + """Build the digits-only pad shown in place of the QWERTY rows.""" + self._numpad_widget = QtWidgets.QWidget(parent=self) + self._numpad_widget.setGeometry(QtCore.QRect(90, 150, 620, 280)) + grid = QtWidgets.QGridLayout(self._numpad_widget) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(6) + grid.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self._numpad_digits = [] + for idx, digit in enumerate("789456123"): + btn = self._create_numpad_button(digit, f"np_{digit}") + btn.setProperty("position", ("left", "", "right")[idx % 3]) + grid.addWidget(btn, idx // 3, idx % 3) + self._numpad_digits.append(btn) + + # Bottom row keeps the digit grid: "." left, "0" centred under 8/5/2. + zero = self._create_numpad_button("0", "np_0") + zero.setProperty("position", "") + grid.addWidget(zero, 3, 1) + self._numpad_digits.append(zero) + + self.np_dot = self._create_numpad_button(".", "np_dot") + self.np_dot.setProperty("position", "left") + grid.addWidget(self.np_dot, 3, 0) + + self.np_delete = self._create_numpad_icon( + "np_delete", ":/dialog/media/btn_icons/no.svg" + ) + self.np_enter = self._create_numpad_icon( + "np_enter", ":/dialog/media/btn_icons/yes.svg" + ) + grid.addWidget(self.np_delete, 0, 3, 2, 1, QtCore.Qt.AlignmentFlag.AlignCenter) + grid.addWidget(self.np_enter, 2, 3, 2, 1, QtCore.Qt.AlignmentFlag.AlignCenter) + + self._numpad_widget.setVisible(False) + def _setup_ui(self) -> None: self.setObjectName("self") self.resize(800, 480) @@ -376,6 +488,8 @@ def _setup_ui(self) -> None: row3_layout.addWidget(btn) self._key_buttons.append(btn) + self._row_widgets = [row1_widget, row2_widget, row3_widget] + # Shift button (left of row 3) self.K_shift = QtWidgets.QPushButton(parent=self) self.K_shift.setGeometry(QtCore.QRect(10, 280, 81, 51)) @@ -420,6 +534,8 @@ def _setup_ui(self) -> None: self.k_Enter.setAutoRepeat(False) self.k_Enter.setObjectName("k_Enter") + self._setup_numpad() + # Back button (top-right) self.numpad_back_btn = IconButton(parent=self) self.numpad_back_btn.setGeometry(QtCore.QRect(720, 20, 60, 60)) diff --git a/tests/util/test_keyboard_page_unit.py b/tests/util/test_keyboard_page_unit.py index 6b6cbc55..5d96d817 100644 --- a/tests/util/test_keyboard_page_unit.py +++ b/tests/util/test_keyboard_page_unit.py @@ -22,7 +22,11 @@ _icon_stub.IconButton = QtWidgets.QPushButton # type: ignore[attr-defined] sys.modules.setdefault("lib.utils.icon_button", _icon_stub) -# Force-reload the real module — the network conftest registers a stub +_numpad_stub = types.ModuleType("lib.utils.numpad_button") +_numpad_stub.NumpadButton = QtWidgets.QPushButton # type: ignore[attr-defined] +sys.modules.setdefault("lib.utils.numpad_button", _numpad_stub) + +# Force-reload the real module: the network conftest registers a stub # that lacks the layout constants we need. for _key in [ "lib.panels.widgets.keyboardPage", @@ -177,6 +181,43 @@ def test_set_value_then_type(self, keyboard): keyboard.value_inserted("d") assert keyboard.current_value == "d" + def test_set_value_then_clear_edits_in_place(self, keyboard): + keyboard.set_value("10.0.0.12") + keyboard.value_inserted("clear") + keyboard.value_inserted("5") + assert keyboard.current_value == "10.0.0.15" + + def test_set_value_then_enter_keeps_prefill(self, keyboard, qtbot): + keyboard.set_value("abc") + with qtbot.waitSignal(keyboard.value_selected, timeout=1000) as sig: + keyboard.value_inserted("enter") + assert sig.args == ["abc"] + + +class TestPatternValidation: + """Pattern validators accept partial entries and reject impossible ones.""" + + @pytest.mark.parametrize( + ("pattern", "value", "ok"), + [ + ("ip", "192.168.1.1", True), + ("ip", "10..", True), + ("ip", "256", False), + ("ip", "1.2.3.4.5", False), + ("ip", "1a", False), + ("hex", "aF09", True), + ("hex", "g", False), + ("int", "-12", True), + ("int", "1.5", False), + ("float", "1.", True), + ("float", "a", False), + ("", "anything", True), + ], + ) + def test_validate_pattern(self, keyboard, pattern, value, ok): + keyboard.setPattern(pattern) + assert keyboard._validate_pattern(value) is ok + class TestDotButton: """Dedicated dot button is always accessible regardless of layout.""" @@ -224,3 +265,78 @@ def test_delete_button_click(self, keyboard, qtbot): def test_back_button_emits_signal(self, keyboard, qtbot): with qtbot.waitSignal(keyboard.request_back, timeout=1000): qtbot.mouseClick(keyboard.numpad_back_btn, QtCore.Qt.MouseButton.LeftButton) + + +class TestNumericOnly: + """Numeric-only swaps the QWERTY rows for the numpad on IP/mask/gateway fields.""" + + _EXTRA_KEYS = ("K_shift", "K_keychange", "K_space", "K_dot", "k_delete", "k_Enter") + + def test_default_is_qwerty(self, keyboard): + assert keyboard._numeric_only is False + assert keyboard._numpad_widget.isHidden() + + def test_enabling_hides_qwerty_rows(self, keyboard): + keyboard.setNumericOnly(True) + assert all(w.isHidden() for w in keyboard._row_widgets) + + def test_enabling_shows_numpad(self, keyboard): + keyboard.setNumericOnly(True) + assert not keyboard._numpad_widget.isHidden() + + def test_enabling_hides_qwerty_only_keys(self, keyboard): + keyboard.setNumericOnly(True) + assert all(getattr(keyboard, n).isHidden() for n in self._EXTRA_KEYS) + + def test_enabling_clears_shift_and_symbols(self, keyboard): + keyboard.K_shift.setChecked(True) + keyboard.symbolsrun = True + keyboard.setNumericOnly(True) + assert keyboard.K_shift.isChecked() is False + assert keyboard.K_keychange.isChecked() is False + assert keyboard.symbolsrun is False + + def test_disabling_restores_qwerty(self, keyboard): + keyboard.setNumericOnly(True) + keyboard.setNumericOnly(False) + assert all(not w.isHidden() for w in keyboard._row_widgets) + assert keyboard._numpad_widget.isHidden() + assert all(not getattr(keyboard, n).isHidden() for n in self._EXTRA_KEYS) + + def test_repeat_enable_is_a_noop(self, keyboard): + keyboard.setNumericOnly(True) + keyboard.K_shift.setChecked(True) + keyboard.setNumericOnly(True) + assert keyboard.K_shift.isChecked() is True + + def test_numpad_keeps_digits_after_toggle_cycle(self, keyboard): + keyboard.setNumericOnly(True) + keyboard.setNumericOnly(False) + keyboard.setNumericOnly(True) + assert not keyboard._numpad_widget.isHidden() + assert keyboard._numeric_only is True + + def test_numpad_keys_insert_and_delete(self, keyboard): + keyboard.setNumericOnly(True) + digits = {b.text(): b for b in keyboard._numpad_digits} + for key in (digits["1"], keyboard.np_dot, digits["0"], digits["7"]): + key.click() + keyboard.np_delete.click() + assert keyboard.current_value == "1.0" + + def test_ip_pattern_rejects_octet_over_255(self, keyboard): + keyboard.setPattern("ip") + keyboard.setNumericOnly(True) + digits = {b.text(): b for b in keyboard._numpad_digits} + for d in "256": + digits[d].click() + assert keyboard.current_value == "25" + + def test_enter_submits_and_restores_qwerty(self, keyboard, qtbot): + keyboard.setNumericOnly(True) + keyboard.set_value("10.0.0.1") + with qtbot.waitSignal(keyboard.value_selected, timeout=1000) as sig: + keyboard.np_enter.click() + assert sig.args == ["10.0.0.1"] + assert keyboard._numeric_only is False + assert keyboard._numpad_widget.isHidden()