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:
1 parent
7d40929341
commit
0d2ffac70f
2 files changed
+92
-6
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user