diff --git a/copier/_user_data.py b/copier/_user_data.py index 1873f7a9a..8690daf6b 100644 --- a/copier/_user_data.py +++ b/copier/_user_data.py @@ -19,11 +19,12 @@ from jinja2 import StrictUndefined, UndefinedError from jinja2.sandbox import SandboxedEnvironment from prompt_toolkit.lexers import PygmentsLexer -from pydantic import ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from pydantic.dataclasses import dataclass from pydantic_core.core_schema import ValidationInfo from pygments.lexers.data import JsonLexer, YamlLexer from questionary.prompts.common import Choice +from typing_extensions import Self from copier._jinja_ext import UnsetError from copier._settings import SettingsModel @@ -201,6 +202,17 @@ class Question: If it is a boolean, it is used directly. If it is a str, it is converted to boolean using a parser similar to YAML, but only for boolean values. + + use_shortcuts: + Condition that, if `True`, allows selecting choice question items via + number shortcuts. Mutually exclusive with `multiselect` and + `use_search_filter`. + + use_search_filter: + Condition that, if `True`, enables filtering choice question items by + typing a search string. Disables j/k navigation, as "j" and "k" can be part + of a prefix and therefore cannot be used for navigation. Mutually exclusive + with `use_shortcuts`. """ var_name: str @@ -219,6 +231,8 @@ class Question: type: str = Field(default="", validate_default=True) validator: str = "" when: str | bool = True + use_shortcuts: bool = False + use_search_filter: bool = False @field_validator("var_name") @classmethod @@ -244,6 +258,19 @@ def _check_secret_question_default_value( raise ValueError("Secret question requires a default value") return v + @model_validator(mode="after") + def _check_no_multiselect_or_search_filter_with_use_shortcuts(self) -> Self: + if self.use_shortcuts: + if self.multiselect: + raise ValueError( + f"[Question Name: `{self.var_name}`]\n `use_shortcuts` & `multiselect` are mutually exclusive\n Use either `use_shortcuts: true` or `multiselect: true`\n " + ) + if self.use_search_filter: + raise ValueError( + f"[Question Name: `{self.var_name}`]\n `use_shortcuts` & `use_search_filter` are mutually exclusive\n Use either `use_shortcuts: true` or `use_search_filter: true`\n " + ) + return self + def cast_answer(self, answer: Any) -> Any: """Cast answer to expected type.""" type_name = self.get_type_name() @@ -417,6 +444,13 @@ def _validate(answer: str) -> str | Literal[True]: result["default"] = False if self.choices: questionary_type = "checkbox" if self.multiselect else "select" + + if self.use_search_filter: + result["use_search_filter"] = True + result["use_jk_keys"] = False + if self.use_shortcuts: + result["use_shortcuts"] = True + choices = self._formatted_choices # Select default choices for a multiselect question. if self.multiselect and isinstance( diff --git a/docs/configuring.md b/docs/configuring.md index cc3586758..519853030 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -193,6 +193,122 @@ Supported keys: - **multiselect**: When set to `true`, allows multiple choices. The answer will be a `list[T]` instead of a `T` where `T` is of type `type`. +- **use_shortcuts**: When set to `true`, allows selecting choice question items via + number shortcuts. Mutually exclusive with `multiselect` and `use_search_filter`. + + !!! example + + ```yaml title="copier.yml" + language: + type: str + help: Which programming language do you use? + use_shortcuts: true + choices: + - python + - node + - c + - c++ + - rust + - zig + - asm + ``` + + Will result in: + + +
+ 🎤 Which programming language do you use?
+ (Use shortcuts or arrow keys)
+ » 1) python
+ 2) node
+ 3) c
+ 4) c++
+ 5) rust
+ 6) zig
+ 7) asm
+
+
+
+ Pressing `5` gives:
+
+
+
+ 🎤 Which programming language do you use?
+ (Use shortcuts or arrow keys)
+ 1) python
+ 2) node
+ 3) c
+ 4) c++
+ » 5) rust
+ 6) zig
+ 7) asm
+
+
+
+- **use_search_filter**: When set to `true`, enables filtering choice question items
+ by typing a search string. Also deactivates the use of `j`/`k` keys for navigation,
+ as these are captured as prompts for the search filter. Mutually exclusive with
+ `use_shortcuts`.
+
+ !!! note
+
+ If `multiselect` is `true`, you cannot use ++space++ in the search, as this would only select the choice item. If it is `false`, ++space++ can be used.
+
+ !!! example
+
+ ```yaml title="copier.yml"
+ language:
+ type: str
+ help: Which programming language do you use?
+ use_search_filter: true
+ choices:
+ - python
+ - node
+ - c
+ - c++
+ - rust
+ - zig
+ - asm
+ ```
+
+
+
+ 🎤 Which programming language do you use?
+ (Use arrow keys, type to filter)
+ » python
+ node
+ c
+ c++
+ rust
+ zig
+ asm
+
+
+
+ ---
+
+ Typing `o`:
+
+
+ + 🎤 Which programming language do you use? + (Use arrow keys, type to filter) + » python + node + + + / o... ++ + + --- + + When the filter fails, all options are displayed. + + --- + + You can use ++backspace++ to modify the search filter. + - **default**: Leave empty to force the user to answer. Provide a default to save them from typing it if it's quite common. When using `choices`, the default must be the choice _value_, not its _key_, and it must match its _type_. If values are quite diff --git a/mkdocs.yml b/mkdocs.yml index f726f7e38..9539bc094 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -59,6 +59,7 @@ markdown_extensions: class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.emoji + - pymdownx.keys - pymdownx.magiclink - toc: permalink: true diff --git a/tests/test_choices.py b/tests/test_choices.py new file mode 100644 index 000000000..597ff698f --- /dev/null +++ b/tests/test_choices.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import pexpect +import pytest + +from .helpers import COPIER_PATH, Keyboard, Spawn, build_file_tree, expect_prompt + + +def test_shortcuts_disabled_by_default( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """Shortcuts are disabled by default, so numbers don't select choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" + + +def test_shortcuts_disabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts are disabled, numbers don't select choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_shortcuts: false + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" + + +def test_shortcuts_enabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts are enabled, numbers select choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_shortcuts: true + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("3") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "third" + + +def test_multiselect_with_shortcuts_not_supported( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When shortcuts and multiselect are both enabled, a ValidationError is raised.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + multiselect: true + choices: + one: first + two: second + three: third + use_shortcuts: true + """ + ) + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + tui.expect_exact(pexpect.EOF) + assert tui.exitstatus != 0 + assert tui.proc.returncode != 0 + assert [ + "pydantic_core._pydantic_core.ValidationError: 1 validation error for Question", + " Value error, [Question Name: `select`]", + " `use_shortcuts` & `multiselect` are mutually exclusive", + " Use either `use_shortcuts: true` or `multiselect: true`", + " [type=value_error, input_value=ArgsKwargs((), {'answers'... 'use_shortcuts': True}), input_type=ArgsKwargs]", + ] == str(tui.before).split("\n")[-7:-2] + + +def test_search_filter_disabled_by_default( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """Search filter is disabled by default, so typing doesn't narrow choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" + + +def test_search_filter_disabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When search filter is disabled, typing doesn't narrow choices.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_search_filter: false + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "first" + + +def test_search_filter_enabled( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """When search filter is enabled, typing narrows choices to matching options.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + use_search_filter: true + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "select", "str", help="Select one option only") + tui.send("tw") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "second" + + +def test_search_filter_and_shortcut_not_supported( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """ + When search filter and shortcuts are enabled, a ValidationError is raised. + """ + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + select: + type: str + help: Select one option only + default: first + choices: + one: first + two: second + three: third + four: forth + use_search_filter: true + use_shortcuts: true + """ + ), + src / "result.jinja": "{{ select }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + tui.expect_exact(pexpect.EOF) + assert tui.exitstatus != 0 + assert tui.proc.returncode != 0 + assert [ + "pydantic_core._pydantic_core.ValidationError: 1 validation error for Question", + " Value error, [Question Name: `select`]", + " `use_shortcuts` & `use_search_filter` are mutually exclusive", + " Use either `use_shortcuts: true` or `use_search_filter: true`", + " [type=value_error, input_value=ArgsKwargs((), {'answers'... 'use_shortcuts': True}), input_type=ArgsKwargs]", + ] == str(tui.before).split("\n")[-7:-2] + + +def test_multiselect_with_search_filter( + tmp_path_factory: pytest.TempPathFactory, spawn: Spawn +) -> None: + """Search filter works with multiselect prompts.""" + src, dst = map(tmp_path_factory.mktemp, ("src", "dst")) + build_file_tree( + { + src / "copier.yml": ( + """\ + checkbox: + type: str + help: Select any option + multiselect: true + choices: + one: first + two: second + three: third + use_search_filter: true + """ + ), + src / "result.jinja": "{{ checkbox }}", + } + ) + tui = spawn(COPIER_PATH + ("copy", str(src), str(dst))) + expect_prompt(tui, "checkbox", "str", help="Select any option") + tui.send("tw ") + tui.send(Keyboard.Enter) + tui.expect_exact(pexpect.EOF) + assert (dst / "result").read_text() == "['second']"