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>
113 lines
5.0 KiB
Python
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)
|