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:
michael
2026-07-16 13:11:48 -07:00
parent 72815e34eb
commit d0339bceb5
6 changed files with 210 additions and 94 deletions

View File

@@ -1,6 +1,7 @@
"""clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client """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 with command-tree autocompletion, live hints, and right-arrow help shown in the completion
:mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the sibling modules.""" dropdown. Mirrors the :mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the
sibling modules."""
from __future__ import annotations from __future__ import annotations
import sys import sys
@@ -10,9 +11,9 @@ from .driver import make_driver, DriverError
from .session import ClientSession from .session import ClientSession
from .completer import ClientCompleter from .completer import ClientCompleter
from .keys import install_key_bindings 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: 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) state = ClientSession(dev, tree)
def bottom_toolbar(): 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: try:
buf = get_app().current_buffer hint = one_line_hint(tree, get_app().current_buffer.text)
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)
except Exception: except Exception:
hint = None hint = None
return ANSI(hint) if hint else _DEFAULT_HINT 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(): with patch_stdout():
while True: while True:
state.help_open = False # each new line starts with help collapsed state.help_open = False # each new line starts with help collapsed
state.help_target = None
try: try:
line = session.prompt(message) line = session.prompt(message)
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.completion import Completer, Completion
from .hints import help_rows
from .tree import parse_option from .tree import parse_option
@@ -17,6 +18,13 @@ class ClientCompleter(Completer):
self._session = session self._session = session
def get_completions(self, document, complete_event): 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 tree = self._session.tree
text = document.text_before_cursor text = document.text_before_cursor
tokens = text.split() tokens = text.split()

View File

@@ -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` — 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
- :func:`one_line_hint` — a one-line relevance hint for the current input (always on). dropdown* (never the bottom bar — the dropdown is where all hints live, per rawcli 65be53d);
- :func:`help_panel` / :func:`panel_for` — the multi-line help shown when the help toggle is open;
:func:`resolve_target` maps the *highlighted* completion back to the node whose help to show. :func:`resolve_target` maps the *highlighted* completion back to the node whose help to show.
""" """
from __future__ import annotations from __future__ import annotations
from .tree import parse_option 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: def _skip(flags) -> bool:
return "--help" in flags or "-h" in flags 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" return f"{' '.join(used)}{len(node.children)} subcommands"
def help_panel(entry) -> str: def help_rows(node):
"""The full multi-line help for a leaf command: description, usage, options, and notes.""" """The full help of a highlighted ``node`` as ``(display, meta)`` rows for the completion
if not entry: dropdown — a leaf's name/usage/options/notes, or an interior node's subcommands. Presented in
return "" the same two-column form the normal completions use. ``[]`` when there is nothing to show."""
out = [f"{_BOLD}{entry['command']}{_RESET}{entry.get('description', '')}",
f"{_DIM}usage:{_RESET} {entry.get('usage', '')}"]
rows = []
for opt in entry.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: if node is None:
return None return []
if node.is_leaf: if node.is_leaf:
return help_panel(node.entry) 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 "")).strip(), desc))
rows += [(n, "example") for n in e.get("notes", [])]
return rows
kids = sorted(node.children) kids = sorted(node.children)
if not kids: rows = [(node.token or "(commands)", f"{len(kids)} subcommands")]
return None for tok in kids:
head = f"{_BOLD}{node.token or '(commands)'}{_RESET}{len(kids)} subcommands" child = node.children[tok]
return head + "\n" + f"{_DIM}{', '.join(kids)}{_RESET}" 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): 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() tokens = (buffer_text or "").split()
at_word = bool(tokens) and not (buffer_text or "").endswith(" ") at_word = bool(tokens) and not (buffer_text or "").endswith(" ")
path = tokens[:-1] if at_word else tokens 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("-"): if ctext.startswith("-"):
for i in range(len(path), -1, -1): for i in range(len(path), -1, -1):
node = tree.node_at(path[:i]) node = tree.node_at(path[:i])
if node is not None and node.is_leaf: if node is not None and node.is_leaf:
return node return node
return None return None
full = path + [ctext] if ctext else path # menu navigation has already inserted the highlighted completion into the buffer
return tree.node_at(full) # (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)

View File

@@ -1,9 +1,10 @@
"""The help-toggle key bindings for clientcli — the centrepiece interaction. """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 When a completion is highlighted in the menu, RIGHT swaps the *dropdown* to the full help of that
toolbar; LEFT (primary) or ESCAPE / Ctrl-g (secondary) collapses it. Only a single ``help_open`` command (name, usage, options, notes as rows); LEFT (primary) or ESCAPE / Ctrl-g (secondary)
flag lives on the session — the toolbar recomputes the *target* from the live highlighted completion restores normal completion. The help never touches the bottom bar — the dropdown is where all hints
each render, so arrowing through the menu re-targets the panel automatically. 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 The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key
wiring is exercised in the live REPL. wiring is exercised in the live REPL.
@@ -11,15 +12,18 @@ wiring is exercised in the live REPL.
from __future__ import annotations from __future__ import annotations
def open_help(session, has_selection: bool) -> bool: def open_help(session, node) -> bool:
"""Open help iff a completion is highlighted. Returns the resulting ``help_open`` state.""" """Open help iff ``node`` resolved to something. Captures it as ``help_target`` so the completer
if has_selection: can render its help. Returns the resulting ``help_open`` state."""
if node is not None:
session.help_target = node
session.help_open = True session.help_open = True
return session.help_open return session.help_open
def close_help(session) -> bool: def close_help(session) -> bool:
session.help_open = False session.help_open = False
session.help_target = None
return session.help_open 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 """Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on
an unknown key.""" an unknown key."""
from prompt_toolkit.filters import Condition, completion_is_selected 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) 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; # Tab commits the highlighted (or first) completion so it sticks and descends a level;
# Shift-Tab keeps prompt_toolkit's default (previous completion). # Shift-Tab keeps prompt_toolkit's default (previous completion).
_bind(kb, "tab", lambda event: apply_tab(event.current_buffer)) _bind(kb, "tab", lambda event: apply_tab(event.current_buffer))
def _toggle(event): # complete_while_typing only re-runs completion on INSERT, so deleting characters leaves the
session.help_open = not session.help_open # 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() event.app.invalidate()
def _close(event): 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() event.app.invalidate()
# RIGHT toggles help for the highlighted completion. With nothing selected the filter is false, # RIGHT opens help for the highlighted completion. With nothing selected (or help already open)
# so the binding doesn't apply and the default cursor-right still works. # the filter is false, so the binding is inert and the default cursor-right still works.
_bind(kb, "right", _toggle, filter=completion_is_selected) _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. # LEFT is the instant back-out; active only while help is open, else default cursor-left.
_bind(kb, "left", _close, filter=help_is_open) _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 # ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only

View File

@@ -14,12 +14,14 @@ _RESET = "\033[0m"
class ClientSession: class ClientSession:
"""Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command """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): def __init__(self, driver: Any, tree: Any):
self.driver = driver self.driver = driver
self.tree = tree self.tree = tree
self.help_open: bool = False self.help_open: bool = False
self.help_target: Any = None
@property @property
def prompt_label(self) -> str: def prompt_label(self) -> str:

View File

@@ -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.tree import CommandTree, load_command_tree, parse_option
from pm3py.cli.clientcli.session import ClientSession from pm3py.cli.clientcli.session import ClientSession
from pm3py.cli.clientcli.completer import ClientCompleter 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.keys import open_help, close_help, install_key_bindings, apply_tab
from pm3py.cli.clientcli.driver import ( from pm3py.cli.clientcli.driver import (
strip_ansi, _extract_output, PROMPT_RE, resolve_client, 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(), "zz") is None
assert one_line_hint(_tree(), "") is None assert one_line_hint(_tree(), "") is None
def test_help_panel_multiline(self): def test_help_rows_leaf(self):
panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"]))) # a leaf's help as dropdown rows: (display, meta) — name, usage, each option, each note
assert "hf mf rdbl" in panel rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
assert "usage:" in panel displays = [d for d, _ in rows]
assert "--blk" in panel and "--key <hex>" in panel metas = [m for _, m in rows]
assert "This help" not in panel # -h/--help is skipped assert ("hf mf rdbl", "Read MIFARE Classic block") == rows[0]
assert "hf mf rdbl --blk 0" in panel # a note 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): def test_help_rows_non_inserting_and_readable(self):
node = _tree().node_at(["hf", "mf", "rdbl"]) # every row carries display + display_meta (the two-column dropdown form)
assert panel_for(node) == help_panel(node.entry) 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): def test_help_rows_interior(self):
panel = _plain(panel_for(_tree().node_at(["hf", "mf"]))) rows = help_rows(_tree().node_at(["hf", "mf"]))
assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel 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): 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) node = resolve_target(_tree(), "hf mf ", cur)
assert node is not None and node.token == "rdbl" and node.is_leaf 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" assert node is not None and node.token == "rdbl"
def test_resolve_target_interior(self): def test_resolve_target_interior(self):
cur = SimpleNamespace(text="mf") cur = SimpleNamespace(text="mf ")
node = resolve_target(_tree(), "hf ", cur) node = resolve_target(_tree(), "hf ", cur)
assert node is not None and node.token == "mf" and not node.is_leaf 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 # --------------------------------------------------------------------------- keys
class TestKeys: class TestKeys:
def test_open_help_requires_selection(self): def test_open_help_requires_a_resolved_node(self):
s = _session() s = _session()
assert open_help(s, has_selection=False) is False assert open_help(s, None) is False # nothing highlighted → no help
assert s.help_open is False assert s.help_open is False and s.help_target is None
assert open_help(s, has_selection=True) is True node = s.tree.node_at(["hf", "mf", "rdbl"])
assert s.help_open is True assert open_help(s, node) is True
assert s.help_open is True and s.help_target is node
def test_close_help(self): def test_close_help(self):
s = _session() s = _session()
s.help_open = True s.help_open = True
s.help_target = object()
assert close_help(s) is False 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): def test_install_does_not_raise(self):
install_key_bindings(KeyBindings(), _session()) 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): def _buffer_with_menu(self, text):
from prompt_toolkit.buffer import Buffer, CompletionState from prompt_toolkit.buffer import Buffer, CompletionState
from prompt_toolkit.completion import CompleteEvent from prompt_toolkit.completion import CompleteEvent
@@ -252,6 +296,32 @@ class TestKeys:
completions=comps, complete_index=None) completions=comps, complete_index=None)
return buff 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): def test_tab_commits_first_completion(self):
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space) # with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
buff = self._buffer_with_menu("hf ") buff = self._buffer_with_menu("hf ")