diff --git a/news/ptk-menu-complete.rst b/news/ptk-menu-complete.rst new file mode 100644 index 000000000..b9f87b47d --- /dev/null +++ b/news/ptk-menu-complete.rst @@ -0,0 +1,28 @@ +**Added:** + +* Environment variable ``$COMPLETION_MODE`` controls kind of TAB completion used with prompt-toolkit shell. + ``default``, the default, retains prior Xonsh behavior: first TAB displays the common prefix of matching completions, + next TAB selects the first or next available completion. + ``menu-complete`` enables TAB behavior like ``readline`` command ``menu-complete``. First TAB selects the first matching + completion, subsequent TABs cycle through available completions till the last one. Next TAB after that displays + the common prefix, then the cycle repeats. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* \ No newline at end of file diff --git a/tests/test_tools.py b/tests/test_tools.py index 706377ae4..08540b12a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -85,6 +85,10 @@ from xonsh.tools import ( all_permutations, register_custom_style, simple_random_choice, + is_completion_mode, + to_completion_mode, + is_completions_display_value, + to_completions_display_value, ) from xonsh.environ import Env @@ -1819,3 +1823,79 @@ def test_register_custom_style(name, styles, refrules): for rule, color in style.styles.items(): if str(rule) in refrules: assert refrules[str(rule)] == color + + +@pytest.mark.parametrize( + "val, exp", + [ + ("default", True), + ("menu-complete", True), + ("def", False), + ("xonsh", False), + ("men", False), + ], +) +def test_is_completion_mode(val, exp): + assert is_completion_mode(val) is exp + + +@pytest.mark.parametrize( + "val, exp", + [ + ("", "default"), + (None, "default"), + ("default", "default"), + ("DEfaULT", "default"), + ("m", "menu-complete"), + ("mEnu_COMPlete", "menu-complete"), + ("menu-complete", "menu-complete"), + ], +) +def test_to_completion_mode(val, exp): + assert to_completion_mode(val) == exp + + +@pytest.mark.parametrize("val", ["de", "defa_ult", "men_", "menu_",]) +def test_to_completion_mode_fail(val): + with pytest.warns(RuntimeWarning): + obs = to_completion_mode(val) + assert obs == "default" + + +@pytest.mark.parametrize( + "val, exp", + [ + ("none", True), + ("single", True), + ("multi", True), + ("", False), + (None, False), + ("argle", False), + ], +) +def test_is_completions_display_value(val, exp): + assert is_completions_display_value(val) == exp + + +@pytest.mark.parametrize( + "val, exp", + [ + ("none", "none"), + (False, "none"), + ("false", "none"), + ("single", "single"), + ("readline", "single"), + ("multi", "multi"), + (True, "multi"), + ("TRUE", "multi"), + ], +) +def test_to_completions_display_value(val, exp): + to_completions_display_value(val) == exp + + +@pytest.mark.parametrize("val", [1, "", "argle"]) +def test_to_completions_display_value_fail(val): + with pytest.warns(RuntimeWarning): + obs = to_completions_display_value(val) + assert obs == "multi" diff --git a/xonsh/environ.py b/xonsh/environ.py index 8387fad20..fb9c39753 100644 --- a/xonsh/environ.py +++ b/xonsh/environ.py @@ -57,6 +57,8 @@ from xonsh.tools import ( is_string_or_callable, is_completions_display_value, to_completions_display_value, + is_completion_mode, + to_completion_mode, is_string_set, csv_to_set, set_to_csv, @@ -851,6 +853,17 @@ def DEFAULT_VARS(): "``$COMPLETIONS_DISPLAY`` is ``single`` or ``multi``. This only affects the " "prompt-toolkit shell.", ), + "COMPLETION_MODE": Var( + is_completion_mode, + to_completion_mode, + str, + "default", + "Mode of tab completion in prompt-toolkit shell (only).\n\n" + "'default', the default, selects the common prefix of completions on first TAB,\n" + "then cycles through all completions.\n" + "'menu-complete' selects the first whole completion on the first TAB, \n" + "then cycles through the remaining completions, then the common prefix.", + ), "COMPLETION_QUERY_LIMIT": Var( is_int, int, diff --git a/xonsh/ptk_shell/key_bindings.py b/xonsh/ptk_shell/key_bindings.py index e578e2a46..7f17d24a7 100644 --- a/xonsh/ptk_shell/key_bindings.py +++ b/xonsh/ptk_shell/key_bindings.py @@ -109,6 +109,12 @@ def tab_insert_indent(): return bool(before_cursor.isspace()) +@Condition +def tab_menu_complete(): + """Checks whether completion mode is `menu-complete`""" + return builtins.__xonsh__.env.get("COMPLETION_MODE") == "menu-complete" + + @Condition def beginning_of_line(): """Check if cursor is at beginning of a line other than the first line in a @@ -206,6 +212,15 @@ def load_xonsh_bindings() -> KeyBindingsBase: env = builtins.__xonsh__.env event.cli.current_buffer.insert_text(env.get("INDENT")) + @handle(Keys.Tab, filter=~tab_insert_indent & tab_menu_complete) + def menu_complete_select(event): + """Start completion in menu-complete mode, or tab to next completion""" + b = event.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=True) + @handle(Keys.ControlX, Keys.ControlE, filter=~has_selection) def open_editor(event): """ Open current buffer in editor """ diff --git a/xonsh/tools.py b/xonsh/tools.py index 989f94b65..d177619c3 100644 --- a/xonsh/tools.py +++ b/xonsh/tools.py @@ -1617,10 +1617,12 @@ def ptk2_color_depth_setter(x): def is_completions_display_value(x): + """Enumerated values of ``$COMPLETIONS_DISPLAY``""" return x in {"none", "single", "multi"} def to_completions_display_value(x): + """Convert user input to value of ``$COMPLETIONS_DISPLAY``""" x = str(x).lower() if x in {"none", "false"}: x = "none" @@ -1636,6 +1638,33 @@ def to_completions_display_value(x): return x +CANONIC_COMPLETION_MODES = frozenset({"default", "menu-complete"}) + + +def is_completion_mode(x): + """Enumerated values of $COMPLETION_MODE""" + return x in CANONIC_COMPLETION_MODES + + +def to_completion_mode(x): + """Convert user input to value of $COMPLETION_MODE""" + y = str(x).casefold().replace("_", "-") + y = ( + "default" + if y in ("", "d", "xonsh", "none", "def") + else "menu-complete" + if y in ("m", "menu", "menu-completion") + else y + ) + if y not in CANONIC_COMPLETION_MODES: + warnings.warn( + f"'{x}' is not valid for $COMPLETION_MODE, must be one of {CANONIC_COMPLETION_MODES}. Using 'default'.", + RuntimeWarning, + ) + y = "default" + return y + + def is_str_str_dict(x): """Tests if something is a str:str dictionary""" return isinstance(x, dict) and all(