Show the Bluetooth PIN as six fixed slots, capped at six digits

MeshCore pairing PINs are always six digits, so the PIN dialog now caps input
at six characters and draws a fixed six-slot field: each typed digit shows as a
bullet and each still-blank slot as a muted centre dot (· ), so the field reads
as a row of PIN boxes rather than a growing masked line. _LineEditor gains an
optional max_length (swallows keys past the cap, truncates an over-long paste)
and a slots render mode backing the field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-09 20:00:46 -04:00
1 parent 7d40929341
commit 0d2ffac70f
2 files changed
+92 -6

No files matched your search

+51 -6
View File
@@ -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:
+41
View File
@@ -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)