Files
pm3py/pm3py/cli/rawcli/entry.py
michael 6e46916dd9 fix(rawcli): raw byte-line autocomplete fires in hex and binary
The digit key handler applied each keystroke as `buf.document = Document(...)`.
Assigning the buffer document resets prompt_toolkit's complete_state and never
re-fires completion, so the live autocomplete menu never appeared during raw byte
entry -- in BOTH hex and binary (only command-name completion worked, since that
path already used insert_text).

type_digit only ever appends the digit (optionally after an auto-space) or drops it,
so the change is always a pure suffix. Apply it with buf.insert_text, which triggers
completion exactly like an ordinary keystroke.

Verified by driving the real key binding: the completion menu now populates on every
digit in hex and binary (before: complete_state None, start_completion never called).
Adds a regression test asserting the handler routes through insert_text; updates the
rawcli roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:31:28 -07:00

113 lines
5.0 KiB
Python

"""Numeric entry: per-byte base-aware input for raw hex/binary, plus the prompt_toolkit key
bindings that drive it.
Raw byte entry mixes bases **per byte** — each byte remembers the base it was typed in, so
``30`` (hex) and ``00000100`` (binary) can sit on one line as ``30 00000100`` and send ``0x30
0x04``. The display stays clean (no ``0x``/``0b`` clutter); the base of each byte is tracked
alongside the text in ``session.byte_bases``. A byte auto-seals at its base width (2 hex / 8
binary) and the next digit starts a fresh byte in the current entry mode; toggling the mode
(Ctrl-/) only changes the base of the *next* byte — it never rewrites what you've typed.
The pure ``type_digit`` / ``group_size`` / ``is_byte_line`` functions carry the logic and are
unit-tested; the key bindings are thin wiring exercised in the live REPL.
"""
from __future__ import annotations
HEX = "hex"
BIN = "bin"
_DIGITS = {HEX: set("0123456789abcdefABCDEF"), BIN: set("01")}
def group_size(mode: str) -> int:
"""Characters per byte group: 2 for hex, 8 for binary."""
return 2 if mode == HEX else 8
def is_byte_line(text: str) -> bool:
"""Whether ``text`` (spaces ignored) is a pure hex/binary byte run — i.e. raw entry, not a
command word or function call. Empty is a byte line (you're about to type bytes)."""
return all(ch in _DIGITS[HEX] for ch in text.replace(" ", ""))
def type_digit(text: str, bases: list[str], mode: str, digit: str) -> tuple[str, list[str]]:
"""Apply one digit keystroke to a raw-byte line, base-aware. Returns ``(new_text, new_bases)``.
A byte seals at its base's width (2 hex / 8 binary); once full, the next digit starts a new
byte in ``mode``. A digit that isn't valid for the byte it would land in is dropped (no
change), so binary bytes can't gain hex digits and vice-versa."""
d = digit.lower()
groups = text.split(" ") if text else []
aligned = len(bases) == len(groups)
# extend the trailing byte if it's still open (present, aligned, below its base width)
if groups and aligned and groups[-1] and len(groups[-1]) < group_size(bases[-1]):
b = bases[-1]
if d in _DIGITS[b]:
groups[-1] += digit
return " ".join(groups), list(bases)
return text, list(bases) # wrong base for this byte — drop
# otherwise begin a new byte in the current entry mode
if d in _DIGITS[mode]:
return (" ".join(groups + [digit]) if groups else digit), list(bases) + [mode]
return text, list(bases)
def reconcile_bases(text: str, bases: list[str]) -> list[str]:
"""Trim ``bases`` to the number of byte groups left in ``text`` (after a backspace/edit). Only
a best-effort end-alignment — enough for append + backspace, which is what raw entry does."""
groups = [g for g in text.split(" ") if g]
return list(bases[:len(groups)])
#: keys that toggle entry mode. Ctrl-/ is emitted as Ctrl-_ (0x1F) by most terminals; c-t is an
#: always-available fallback that power users can rely on regardless of terminal.
TOGGLE_KEYS = ("c-_", "c-t")
def _bind(kb, key, handler) -> None:
"""Add a binding, ignoring key names a given prompt_toolkit build doesn't accept."""
try:
kb.add(key)(handler)
except ValueError:
pass
def install_key_bindings(kb, session) -> None:
"""Wire the entry-mode toggle (Ctrl-/ via Ctrl-_, plus Ctrl-t) and per-byte base-aware digit
entry onto ``kb``. ``session`` is the :class:`RawSession` whose ``entry_mode`` is toggled/read
and whose ``byte_bases`` records each raw byte's base. Never raises on an unknown key name."""
def _toggle(event):
session.toggle_entry_mode() # only flips the mode — the next byte you type uses it
event.app.invalidate() # redraw the breadcrumb
for key in TOGGLE_KEYS:
_bind(kb, key, _toggle)
def _digit(event):
buf = event.current_buffer
d = event.data
text = buf.text
# only base-group a raw byte line; a command/function-call being typed inserts verbatim
if not is_byte_line(text.replace(" ", "") + d):
buf.insert_text(d)
return
new_text, session.byte_bases = type_digit(text, session.byte_bases, session.entry_mode, d)
# Apply the change as an append via insert_text, NOT `buf.document = …`: assigning the
# document resets prompt_toolkit's complete_state and never re-fires completion, which
# killed the live autocomplete menu during raw byte entry. type_digit only ever appends
# (optionally after an auto-space) or drops, so the delta is always a pure suffix.
suffix = new_text[len(text):]
if suffix:
buf.insert_text(suffix)
for ch in sorted(_DIGITS[HEX] | _DIGITS[BIN]):
_bind(kb, ch, _digit)
def _backspace(event):
buf = event.current_buffer
buf.delete_before_cursor()
session.byte_bases = reconcile_bases(buf.text, session.byte_bases)
for key in ("backspace", "c-h"):
_bind(kb, key, _backspace)