diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index 75fac2b..1406219 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -45,10 +45,17 @@ class _LineEditor: cursor position; rendering shows the cursor as a reverse-video cell. """ - def __init__(self, initial: str = "") -> None: - """Start the editor with ``initial`` text and the cursor at its end.""" + def __init__(self, initial: str = "", *, max_length: Optional[int] = None) -> None: + """Start the editor with ``initial`` text and the cursor at its end. + + Args: + initial: The starting buffer contents. + max_length: Optional hard cap on the number of characters; further insertions are + dropped (or a paste truncated to fit). ``None`` leaves the length unbounded. + """ self.text = initial self.cursor = len(initial) + self._max_length = max_length def edit(self, action: str, data: str = "") -> bool: """Apply an editing action, returning ``True`` if it changed the buffer/cursor. @@ -61,6 +68,11 @@ class _LineEditor: ``True`` if the action was an editing action handled here. """ if action == "text" and data.isprintable(): + if self._max_length is not None: + room = self._max_length - len(self.text) + if room <= 0: + return False # at capacity — swallow the key without changing the buffer + data = data[:room] # a multi-char paste fills only the remaining slots self.text = self.text[: self.cursor] + data + self.text[self.cursor :] self.cursor += len(data) elif action == "backspace" and self.cursor > 0: @@ -112,7 +124,9 @@ class _LineEditor: i += 1 return i - def render(self, mask: bool = False, *, overflow_at: Optional[int] = None) -> Text: + def render( + self, mask: bool = False, *, overflow_at: Optional[int] = None, slots: Optional[int] = None + ) -> Text: """Render the current line with a reverse-video cursor cell. Args: @@ -120,7 +134,12 @@ class _LineEditor: overflow_at: Character index at which the text spills past a byte budget; that character and everything after it are shown in the error style so the user can see exactly what to trim. ``None`` (the default) styles the line plainly. + slots: When set, render exactly this many fixed positions (a masked PIN field): + typed positions show a bullet, still-blank ones a muted centre dot. Takes + precedence over ``mask``/``overflow_at``. """ + if slots is not None: + return self._render_slots(slots) shown = "•" * len(self.text) if mask else self.text text = Text("› ", style="accent") for i, ch in enumerate(shown): @@ -133,6 +152,27 @@ class _LineEditor: text.append(" ", style="reverse") return text + def _render_slots(self, slots: int) -> Text: + """Render a fixed-width masked field: filled bullets and centred dots for blanks. + + Used for PIN entry — each of ``slots`` positions shows a bullet (``•``) once typed and + a muted centre dot (``·``) while still blank, spaced apart so the field reads as a row + of PIN boxes rather than a growing line. The cursor position is drawn reverse-video. + """ + filled = len(self.text) + text = Text("› ", style="accent") + for i in range(slots): + if i: + text.append(" ") + glyph = "•" if i < filled else "·" + if i == self.cursor: + text.append(glyph, style="reverse") # the active slot + elif i < filled: + text.append(glyph) # an entered digit + else: + text.append(glyph, style="muted") # a blank still to fill + return text + class TextScreen(Screen): """A validated single-line text prompt. Resolves with the string, or CANCEL on Esc.""" @@ -210,6 +250,10 @@ class PinDialog(Screen): footer_hint = "Enter connect · Esc cancel" + #: MeshCore pairing PINs are a fixed six digits, so the field is capped at six characters + #: and drawn as six slots (typed digits as bullets, blanks as centre dots). + PIN_LENGTH = 6 + def __init__(self, device_name: str, *, error: str = "", help_text: str = "") -> None: """Build the PIN dialog. @@ -224,14 +268,15 @@ class PinDialog(Screen): self._device = device_name self._error = error self._help = help_text - self._editor = _LineEditor("") + self._editor = _LineEditor("", max_length=self.PIN_LENGTH) def render_body(self, width: int) -> list[str]: - """Render the prompt, the masked field, any hint, and a rejected-PIN error.""" + """Render the prompt, the six-slot PIN field, any hint, and a rejected-PIN error.""" prompt = Text() prompt.append(self._device, style="brand") prompt.append(" needs a pairing PIN to connect.") - parts: list[RenderableType] = [prompt, Text(""), self._editor.render(mask=True)] + field = self._editor.render(slots=self.PIN_LENGTH) + parts: list[RenderableType] = [prompt, Text(""), field] if self._help: parts.append(Text(self._help, style="muted")) if self._error: diff --git a/tests/test_tui.py b/tests/test_tui.py index bbc5686..49d4eb6 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -419,6 +419,47 @@ def test_text_screen_password_masks() -> None: assert "•" in rendered +def test_line_editor_caps_length_and_truncates_paste() -> None: + """A max_length editor swallows keys past the cap and truncates an over-long paste.""" + from meshterm.ui.tui.prompt import _LineEditor + + editor = _LineEditor("", max_length=6) + for ch in "123456": + assert editor.edit("text", ch) is True + assert editor.edit("text", "9") is False # at capacity — key swallowed, buffer unchanged + assert editor.text == "123456" + + pasted = _LineEditor("", max_length=6) + pasted.edit("text", "12345678") # one over-long insert + assert pasted.text == "123456" # filled only the six available slots + + +def test_pin_dialog_shows_six_slots_with_dots_for_blanks() -> None: + """The PIN field is six fixed slots: bullets for typed digits, centre dots for blanks.""" + from meshterm.ui.tui.prompt import PinDialog + + dialog = PinDialog("MeshCore-Homestead") + # Empty: six blank centre dots, no bullets yet. + field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain + assert field.count("·") == 6 + assert "•" not in field + + # After three digits: three bullets, three remaining centre dots. + for ch in "701": + dialog.handle("text", ch) + field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain + assert field.count("•") == 3 + assert field.count("·") == 3 + + # A full six-digit PIN fills every slot; typing more is capped at six. + for ch in "307999": + dialog.handle("text", ch) + assert dialog._editor.text == "123456" + field = dialog._editor.render(slots=PinDialog.PIN_LENGTH).plain + assert field.count("•") == 6 + assert "·" not in field + + def test_confirm_toggle_and_default() -> None: """The confirm toggles with arrows/letters and returns the chosen bool.""" screen = ConfirmScreen("sure?", default=True)