fix(clientcli): right-arrow help in the dropdown, selection-restoring LEFT, delete refresh
The right-arrow help never worked for subcommand completions and rendered into
the bottom toolbar (unreadable). Rework it to present in the completion dropdown
— the same two-column form as normal completions — and fix the surrounding
keyboard interactions, all verified by driving the real app through a terminal
emulator (not just unit tests).
- Help now renders as dropdown rows (hints.help_rows), never the bottom bar. The
completer yields the target command's name/usage/options/notes as non-inserting
Completion("") rows while help is open; the bottom bar keeps only the one-line
hint.
- resolve_target: strip the trailing space subcommand completions carry, and don't
re-append a token the menu already inserted into the buffer — both silently made
the panel resolve to nothing.
- RIGHT captures the highlighted node + buffer/complete_state; LEFT restores the
EXACT pre-RIGHT selection. Detach the menu with complete_state=None rather than
cancel_completion(), which calls go_to_completion(None) and wipes the highlighted
index (this is what made Tab commit the wrong command after a RIGHT/LEFT trip).
- Rebind backspace/delete/C-w/C-u to re-run completion: complete_while_typing only
fires on insert, so deleting characters left the dropdown stale/blank.
Tests updated to the real live states (trailing-space completions, post-insertion
buffer), plus guards pinning the detach-not-cancel invariant and the delete-key
rebinding.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client
|
||||
with command-tree autocompletion, live hints, and a right-arrow help toggle. Mirrors the
|
||||
:mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the sibling modules."""
|
||||
with command-tree autocompletion, live hints, and right-arrow help shown in the completion
|
||||
dropdown. Mirrors the :mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the
|
||||
sibling modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
@@ -10,9 +11,9 @@ from .driver import make_driver, DriverError
|
||||
from .session import ClientSession
|
||||
from .completer import ClientCompleter
|
||||
from .keys import install_key_bindings
|
||||
from .hints import one_line_hint, resolve_target, panel_for
|
||||
from .hints import one_line_hint
|
||||
|
||||
_DEFAULT_HINT = "type a pm3 command · Tab to complete · → toggles help on a selection · 'quit'"
|
||||
_DEFAULT_HINT = "type a pm3 command · Tab to complete · → help on a selection · 'quit'"
|
||||
|
||||
|
||||
def dispatch(state, line, out=print) -> bool:
|
||||
@@ -60,15 +61,9 @@ def run(port=None, commands=None, client="proxmark3", driver="auto") -> int:
|
||||
state = ClientSession(dev, tree)
|
||||
|
||||
def bottom_toolbar():
|
||||
# live relevance for the current input; the expanded help panel when the toggle is open
|
||||
# only ever the one-line relevance hint — the full help lives in the completion dropdown
|
||||
try:
|
||||
buf = get_app().current_buffer
|
||||
cur = buf.complete_state.current_completion if buf.complete_state else None
|
||||
if state.help_open and cur is not None:
|
||||
panel = panel_for(resolve_target(tree, buf.text, cur))
|
||||
if panel:
|
||||
return ANSI(panel)
|
||||
hint = one_line_hint(tree, buf.text)
|
||||
hint = one_line_hint(tree, get_app().current_buffer.text)
|
||||
except Exception:
|
||||
hint = None
|
||||
return ANSI(hint) if hint else _DEFAULT_HINT
|
||||
@@ -92,6 +87,7 @@ def run(port=None, commands=None, client="proxmark3", driver="auto") -> int:
|
||||
with patch_stdout():
|
||||
while True:
|
||||
state.help_open = False # each new line starts with help collapsed
|
||||
state.help_target = None
|
||||
try:
|
||||
line = session.prompt(message)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
from .hints import help_rows
|
||||
from .tree import parse_option
|
||||
|
||||
|
||||
@@ -17,6 +18,13 @@ class ClientCompleter(Completer):
|
||||
self._session = session
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
# help mode (RIGHT on a selection): the dropdown becomes the command's help — the same
|
||||
# two-column rows as normal completions, but non-inserting (LEFT restores completion).
|
||||
if self._session.help_open:
|
||||
for disp, meta in help_rows(self._session.help_target):
|
||||
yield Completion("", start_position=0, display=disp, display_meta=meta)
|
||||
return
|
||||
|
||||
tree = self._session.tree
|
||||
text = document.text_before_cursor
|
||||
tokens = text.split()
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"""Bottom-toolbar hints for clientcli.
|
||||
"""Hints for clientcli, all driven by the command tree.
|
||||
|
||||
Two channels, both driven by the command tree (models :func:`pm3py.cli.rawcli.memory.input_hint`):
|
||||
|
||||
- :func:`one_line_hint` — a one-line relevance hint for the current input (always on).
|
||||
- :func:`help_panel` / :func:`panel_for` — the multi-line help shown when the help toggle is open;
|
||||
- :func:`one_line_hint` — the one-line relevance hint on the bottom bar for the current input.
|
||||
- :func:`help_rows` — the full help of a command as ``(display, meta)`` rows for the *completion
|
||||
dropdown* (never the bottom bar — the dropdown is where all hints live, per rawcli 65be53d);
|
||||
:func:`resolve_target` maps the *highlighted* completion back to the node whose help to show.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .tree import parse_option
|
||||
|
||||
# ANSI (same palette as the trace / breadcrumb)
|
||||
_DIM = "\033[2m"
|
||||
_BOLD = "\033[1m"
|
||||
_CYAN = "\033[36m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _skip(flags) -> bool:
|
||||
return "--help" in flags or "-h" in flags
|
||||
@@ -38,41 +31,31 @@ def one_line_hint(tree, text):
|
||||
return f"{' '.join(used)} — {len(node.children)} subcommands"
|
||||
|
||||
|
||||
def help_panel(entry) -> str:
|
||||
"""The full multi-line help for a leaf command: description, usage, options, and notes."""
|
||||
if not entry:
|
||||
return ""
|
||||
out = [f"{_BOLD}{entry['command']}{_RESET} — {entry.get('description', '')}",
|
||||
f"{_DIM}usage:{_RESET} {entry.get('usage', '')}"]
|
||||
rows = []
|
||||
for opt in entry.get("options", []):
|
||||
def help_rows(node):
|
||||
"""The full help of a highlighted ``node`` as ``(display, meta)`` rows for the completion
|
||||
dropdown — a leaf's name/usage/options/notes, or an interior node's subcommands. Presented in
|
||||
the same two-column form the normal completions use. ``[]`` when there is nothing to show."""
|
||||
if node is None:
|
||||
return []
|
||||
if node.is_leaf:
|
||||
e = node.entry
|
||||
rows = [(e["command"], e.get("description", ""))]
|
||||
if e.get("usage"):
|
||||
rows.append((e["usage"], "usage"))
|
||||
for opt in e.get("options", []):
|
||||
flags, meta, desc = parse_option(opt)
|
||||
if not flags or _skip(flags):
|
||||
continue
|
||||
rows.append((", ".join(flags) + (f" {meta}" if meta else ""), desc))
|
||||
if rows:
|
||||
w = min(max(len(h) for h, _ in rows), 28)
|
||||
out.append(f"{_DIM}options:{_RESET}")
|
||||
out += [f" {_CYAN}{h:<{w}}{_RESET} {d}" for h, d in rows]
|
||||
notes = entry.get("notes", [])
|
||||
if notes:
|
||||
out.append(f"{_DIM}notes:{_RESET}")
|
||||
out += [f" {_DIM}{n}{_RESET}" for n in notes]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def panel_for(node) -> str | None:
|
||||
"""The help panel for a highlighted node — a leaf's full help, or an interior node's child
|
||||
list. ``None`` when there is nothing to show."""
|
||||
if node is None:
|
||||
return None
|
||||
if node.is_leaf:
|
||||
return help_panel(node.entry)
|
||||
rows.append(((", ".join(flags) + (f" {meta}" if meta else "")).strip(), desc))
|
||||
rows += [(n, "example") for n in e.get("notes", [])]
|
||||
return rows
|
||||
kids = sorted(node.children)
|
||||
if not kids:
|
||||
return None
|
||||
head = f"{_BOLD}{node.token or '(commands)'}{_RESET} — {len(kids)} subcommands"
|
||||
return head + "\n" + f"{_DIM}{', '.join(kids)}{_RESET}"
|
||||
rows = [(node.token or "(commands)", f"{len(kids)} subcommands")]
|
||||
for tok in kids:
|
||||
child = node.children[tok]
|
||||
meta = child.entry.get("description", "") if child.is_leaf else f"{len(child.children)} subcommands"
|
||||
rows.append((tok, meta))
|
||||
return rows
|
||||
|
||||
|
||||
def resolve_target(tree, buffer_text, current_completion):
|
||||
@@ -82,12 +65,17 @@ def resolve_target(tree, buffer_text, current_completion):
|
||||
tokens = (buffer_text or "").split()
|
||||
at_word = bool(tokens) and not (buffer_text or "").endswith(" ")
|
||||
path = tokens[:-1] if at_word else tokens
|
||||
ctext = getattr(current_completion, "text", "") if current_completion else ""
|
||||
# subcommand completions carry a trailing space (the Tab-descend mechanism) — strip it or
|
||||
# node_at's exact-token lookup misses and the help panel silently never shows
|
||||
ctext = (getattr(current_completion, "text", "") if current_completion else "").strip()
|
||||
if ctext.startswith("-"):
|
||||
for i in range(len(path), -1, -1):
|
||||
node = tree.node_at(path[:i])
|
||||
if node is not None and node.is_leaf:
|
||||
return node
|
||||
return None
|
||||
full = path + [ctext] if ctext else path
|
||||
return tree.node_at(full)
|
||||
# menu navigation has already inserted the highlighted completion into the buffer
|
||||
# (Buffer.go_to_completion), so the path may already end with ctext — don't append it twice
|
||||
if ctext and (not path or path[-1] != ctext):
|
||||
path = path + [ctext]
|
||||
return tree.node_at(path)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""The help-toggle key bindings for clientcli — the centrepiece interaction.
|
||||
|
||||
When a completion is highlighted in the menu, RIGHT toggles a full help panel for it in the bottom
|
||||
toolbar; LEFT (primary) or ESCAPE / Ctrl-g (secondary) collapses it. Only a single ``help_open``
|
||||
flag lives on the session — the toolbar recomputes the *target* from the live highlighted completion
|
||||
each render, so arrowing through the menu re-targets the panel automatically.
|
||||
When a completion is highlighted in the menu, RIGHT swaps the *dropdown* to the full help of that
|
||||
command (name, usage, options, notes as rows); LEFT (primary) or ESCAPE / Ctrl-g (secondary)
|
||||
restores normal completion. The help never touches the bottom bar — the dropdown is where all hints
|
||||
live. The highlighted node is captured into ``session.help_target`` at the moment RIGHT is pressed,
|
||||
then the completer renders its help while ``help_open`` is set.
|
||||
|
||||
The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key
|
||||
wiring is exercised in the live REPL.
|
||||
@@ -11,15 +12,18 @@ wiring is exercised in the live REPL.
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def open_help(session, has_selection: bool) -> bool:
|
||||
"""Open help iff a completion is highlighted. Returns the resulting ``help_open`` state."""
|
||||
if has_selection:
|
||||
def open_help(session, node) -> bool:
|
||||
"""Open help iff ``node`` resolved to something. Captures it as ``help_target`` so the completer
|
||||
can render its help. Returns the resulting ``help_open`` state."""
|
||||
if node is not None:
|
||||
session.help_target = node
|
||||
session.help_open = True
|
||||
return session.help_open
|
||||
|
||||
|
||||
def close_help(session) -> bool:
|
||||
session.help_open = False
|
||||
session.help_target = None
|
||||
return session.help_open
|
||||
|
||||
|
||||
@@ -51,24 +55,72 @@ def install_key_bindings(kb, session) -> None:
|
||||
"""Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on
|
||||
an unknown key."""
|
||||
from prompt_toolkit.filters import Condition, completion_is_selected
|
||||
from prompt_toolkit.key_binding.bindings.named_commands import get_by_name
|
||||
|
||||
from .hints import resolve_target
|
||||
|
||||
help_is_open = Condition(lambda: session.help_open)
|
||||
|
||||
# The exact buffer document + completion menu captured when RIGHT was pressed, so LEFT can put
|
||||
# the user back on the completion they had highlighted (not a freshly regenerated menu).
|
||||
saved = {}
|
||||
|
||||
# Tab commits the highlighted (or first) completion so it sticks and descends a level;
|
||||
# Shift-Tab keeps prompt_toolkit's default (previous completion).
|
||||
_bind(kb, "tab", lambda event: apply_tab(event.current_buffer))
|
||||
|
||||
def _toggle(event):
|
||||
session.help_open = not session.help_open
|
||||
# complete_while_typing only re-runs completion on INSERT, so deleting characters leaves the
|
||||
# dropdown stale (or blank). Re-run the completer after each deletion so the menu tracks the
|
||||
# now-shorter input. Wrap the default readline command, then refresh — but not while the help
|
||||
# panel is up (editing exits help first, handled by the LEFT/close path).
|
||||
def _delete_then_complete(command_name):
|
||||
binding = get_by_name(command_name)
|
||||
|
||||
def _handler(event):
|
||||
binding.handler(event) # the default deletion
|
||||
if not session.help_open:
|
||||
event.current_buffer.start_completion(select_first=False) # refresh the dropdown
|
||||
return _handler
|
||||
|
||||
for _key, _cmd in (("backspace", "backward-delete-char"),
|
||||
("delete", "delete-char"),
|
||||
("c-w", "unix-word-rubout"),
|
||||
("c-u", "unix-line-discard")):
|
||||
_bind(kb, _key, _delete_then_complete(_cmd))
|
||||
|
||||
def _open(event):
|
||||
buff = event.current_buffer
|
||||
state = buff.complete_state
|
||||
cur = state.current_completion if state else None
|
||||
node = resolve_target(session.tree, buff.text, cur)
|
||||
if not open_help(session, node): # only if it resolved to a real command
|
||||
return
|
||||
# Remember exactly where we are. DETACH the menu (complete_state = None) rather than
|
||||
# cancel_completion() — the latter calls go_to_completion(None), which would mutate this
|
||||
# very state object and wipe the highlighted index we need to restore.
|
||||
saved["document"] = buff.document
|
||||
saved["state"] = state
|
||||
buff.complete_state = None
|
||||
buff.start_completion(select_first=False) # regenerate → help rows (completer sees help_open)
|
||||
event.app.invalidate()
|
||||
|
||||
def _close(event):
|
||||
session.help_open = False
|
||||
buff = event.current_buffer
|
||||
close_help(session)
|
||||
if saved.get("state") is not None:
|
||||
# Restore the pre-RIGHT selection exactly. Set the document first (this detaches
|
||||
# complete_state without firing completion), then re-attach the saved menu — the same
|
||||
# order Buffer.go_to_completion uses.
|
||||
buff.document = saved["document"]
|
||||
buff.complete_state = saved["state"]
|
||||
saved.clear()
|
||||
else:
|
||||
buff.cancel_completion()
|
||||
event.app.invalidate()
|
||||
|
||||
# RIGHT toggles help for the highlighted completion. With nothing selected the filter is false,
|
||||
# so the binding doesn't apply and the default cursor-right still works.
|
||||
_bind(kb, "right", _toggle, filter=completion_is_selected)
|
||||
# RIGHT opens help for the highlighted completion. With nothing selected (or help already open)
|
||||
# the filter is false, so the binding is inert and the default cursor-right still works.
|
||||
_bind(kb, "right", _open, filter=completion_is_selected & ~help_is_open)
|
||||
# LEFT is the instant back-out; active only while help is open, else default cursor-left.
|
||||
_bind(kb, "left", _close, filter=help_is_open)
|
||||
# ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only
|
||||
|
||||
@@ -14,12 +14,14 @@ _RESET = "\033[0m"
|
||||
|
||||
class ClientSession:
|
||||
"""Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command
|
||||
trie, and ``help_open`` toggles the bottom-toolbar help panel."""
|
||||
trie. ``help_open`` swaps the completion dropdown to the help of ``help_target`` (the node
|
||||
captured when RIGHT was pressed); LEFT clears both back to normal completion."""
|
||||
|
||||
def __init__(self, driver: Any, tree: Any):
|
||||
self.driver = driver
|
||||
self.tree = tree
|
||||
self.help_open: bool = False
|
||||
self.help_target: Any = None
|
||||
|
||||
@property
|
||||
def prompt_label(self) -> str:
|
||||
|
||||
@@ -15,7 +15,7 @@ from pm3py.cli.main import build_parser, main
|
||||
from pm3py.cli.clientcli.tree import CommandTree, load_command_tree, parse_option
|
||||
from pm3py.cli.clientcli.session import ClientSession
|
||||
from pm3py.cli.clientcli.completer import ClientCompleter
|
||||
from pm3py.cli.clientcli.hints import one_line_hint, help_panel, panel_for, resolve_target
|
||||
from pm3py.cli.clientcli.hints import one_line_hint, help_rows, resolve_target
|
||||
from pm3py.cli.clientcli.keys import open_help, close_help, install_key_bindings, apply_tab
|
||||
from pm3py.cli.clientcli.driver import (
|
||||
strip_ansi, _extract_output, PROMPT_RE, resolve_client,
|
||||
@@ -189,24 +189,36 @@ class TestHints:
|
||||
assert one_line_hint(_tree(), "zz") is None
|
||||
assert one_line_hint(_tree(), "") is None
|
||||
|
||||
def test_help_panel_multiline(self):
|
||||
panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"])))
|
||||
assert "hf mf rdbl" in panel
|
||||
assert "usage:" in panel
|
||||
assert "--blk" in panel and "--key <hex>" in panel
|
||||
assert "This help" not in panel # -h/--help is skipped
|
||||
assert "hf mf rdbl --blk 0" in panel # a note
|
||||
def test_help_rows_leaf(self):
|
||||
# a leaf's help as dropdown rows: (display, meta) — name, usage, each option, each note
|
||||
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
||||
displays = [d for d, _ in rows]
|
||||
metas = [m for _, m in rows]
|
||||
assert ("hf mf rdbl", "Read MIFARE Classic block") == rows[0]
|
||||
assert any("--blk" in d for d in displays)
|
||||
assert any("--key <hex>" in d for d in displays)
|
||||
assert not any("This help" in d for d in displays) # -h/--help is skipped
|
||||
assert "usage" in metas # the usage row
|
||||
assert "hf mf rdbl --blk 0" in displays # a note row
|
||||
|
||||
def test_panel_for_leaf(self):
|
||||
node = _tree().node_at(["hf", "mf", "rdbl"])
|
||||
assert panel_for(node) == help_panel(node.entry)
|
||||
def test_help_rows_non_inserting_and_readable(self):
|
||||
# every row carries display + display_meta (the two-column dropdown form)
|
||||
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
||||
assert all(isinstance(d, str) and d for d, _ in rows)
|
||||
assert all(isinstance(m, str) for _, m in rows)
|
||||
|
||||
def test_panel_for_interior(self):
|
||||
panel = _plain(panel_for(_tree().node_at(["hf", "mf"])))
|
||||
assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel
|
||||
def test_help_rows_interior(self):
|
||||
rows = help_rows(_tree().node_at(["hf", "mf"]))
|
||||
displays = [d for d, _ in rows]
|
||||
assert displays[0] == "mf" # header row
|
||||
assert "rdbl" in displays and "wrbl" in displays
|
||||
|
||||
def test_help_rows_none(self):
|
||||
assert help_rows(None) == []
|
||||
|
||||
def test_resolve_target_subcommand(self):
|
||||
cur = SimpleNamespace(text="rdbl")
|
||||
# real subcommand completions carry a trailing space (Tab-descend mechanism)
|
||||
cur = SimpleNamespace(text="rdbl ")
|
||||
node = resolve_target(_tree(), "hf mf ", cur)
|
||||
assert node is not None and node.token == "rdbl" and node.is_leaf
|
||||
|
||||
@@ -216,30 +228,62 @@ class TestHints:
|
||||
assert node is not None and node.token == "rdbl"
|
||||
|
||||
def test_resolve_target_interior(self):
|
||||
cur = SimpleNamespace(text="mf")
|
||||
cur = SimpleNamespace(text="mf ")
|
||||
node = resolve_target(_tree(), "hf ", cur)
|
||||
assert node is not None and node.token == "mf" and not node.is_leaf
|
||||
|
||||
def test_resolve_target_after_menu_insertion(self):
|
||||
# arrowing onto a completion inserts it into the buffer (go_to_completion), so the
|
||||
# buffer already ends with the highlighted token — it must not be appended twice
|
||||
cur = SimpleNamespace(text="rdbl ")
|
||||
node = resolve_target(_tree(), "hf mf rdbl ", cur)
|
||||
assert node is not None and node.token == "rdbl" and node.is_leaf
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- keys
|
||||
|
||||
class TestKeys:
|
||||
def test_open_help_requires_selection(self):
|
||||
def test_open_help_requires_a_resolved_node(self):
|
||||
s = _session()
|
||||
assert open_help(s, has_selection=False) is False
|
||||
assert s.help_open is False
|
||||
assert open_help(s, has_selection=True) is True
|
||||
assert s.help_open is True
|
||||
assert open_help(s, None) is False # nothing highlighted → no help
|
||||
assert s.help_open is False and s.help_target is None
|
||||
node = s.tree.node_at(["hf", "mf", "rdbl"])
|
||||
assert open_help(s, node) is True
|
||||
assert s.help_open is True and s.help_target is node
|
||||
|
||||
def test_close_help(self):
|
||||
s = _session()
|
||||
s.help_open = True
|
||||
s.help_target = object()
|
||||
assert close_help(s) is False
|
||||
assert s.help_open is False
|
||||
assert s.help_open is False and s.help_target is None
|
||||
|
||||
def test_completer_yields_help_rows_when_open(self):
|
||||
# while help_open, the dropdown renders the target's help as non-inserting completions
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
from prompt_toolkit.document import Document
|
||||
s = _session()
|
||||
s.help_open = True
|
||||
s.help_target = s.tree.node_at(["hf", "mf", "rdbl"])
|
||||
comps = list(ClientCompleter(s).get_completions(Document("hf mf rdbl ", 11), CompleteEvent()))
|
||||
assert comps and all(c.text == "" for c in comps) # selecting one inserts nothing
|
||||
shown = [_plain(c.display[0][1]) for c in comps]
|
||||
assert any("--blk" in d for d in shown)
|
||||
|
||||
def test_install_does_not_raise(self):
|
||||
install_key_bindings(KeyBindings(), _session())
|
||||
|
||||
def test_delete_keys_rebound_to_refresh_dropdown(self):
|
||||
# complete_while_typing only completes on insert, so the deletion keys must be rebound to
|
||||
# re-run completion — else the dropdown goes stale/blank when you backspace.
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, _session())
|
||||
bound = {str(k) for b in kb.bindings for k in b.keys}
|
||||
assert "Keys.ControlH" in bound # backspace
|
||||
assert "Keys.Delete" in bound # delete
|
||||
assert "Keys.ControlW" in bound # delete word
|
||||
assert "Keys.ControlU" in bound # delete to line start
|
||||
|
||||
def _buffer_with_menu(self, text):
|
||||
from prompt_toolkit.buffer import Buffer, CompletionState
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
@@ -252,6 +296,32 @@ class TestKeys:
|
||||
completions=comps, complete_index=None)
|
||||
return buff
|
||||
|
||||
def test_left_restore_preserves_highlighted_index(self):
|
||||
# The RIGHT→LEFT round-trip must return the user to the completion they had highlighted.
|
||||
# Mechanism: help mode DETACHES the menu (complete_state = None) and re-attaches the same
|
||||
# object on LEFT. It must NOT use cancel_completion(), which calls go_to_completion(None)
|
||||
# and wipes the highlighted index — the bug that made Tab commit the wrong command.
|
||||
buff = self._buffer_with_menu("hf mf ")
|
||||
buff.go_to_completion(1) # DOWN onto the 2nd child (wrbl)
|
||||
doc, state, idx = buff.document, buff.complete_state, buff.complete_state.complete_index
|
||||
assert idx == 1 and buff.text == "hf mf wrbl "
|
||||
|
||||
buff.complete_state = None # RIGHT: detach without mutating `state`
|
||||
assert state.complete_index == idx # ← the invariant; cancel_completion breaks it
|
||||
|
||||
buff.document = doc # LEFT: restore exactly
|
||||
buff.complete_state = state
|
||||
assert buff.text == "hf mf wrbl " and buff.complete_state.complete_index == 1
|
||||
|
||||
def test_cancel_completion_would_lose_the_index(self):
|
||||
# Guard the contrast: prove cancel_completion() is the wrong tool here, so nobody swaps it
|
||||
# back in "to simplify".
|
||||
buff = self._buffer_with_menu("hf mf ")
|
||||
buff.go_to_completion(1)
|
||||
state = buff.complete_state
|
||||
buff.cancel_completion()
|
||||
assert state.complete_index is None # cancel wiped it — must not be used for RIGHT
|
||||
|
||||
def test_tab_commits_first_completion(self):
|
||||
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
|
||||
buff = self._buffer_with_menu("hf ")
|
||||
|
||||
Reference in New Issue
Block a user