Skip to content

Conversation

@RR5555
Copy link
Contributor

@RR5555 RR5555 commented Jan 8, 2026

PR Description

This PR adds the questionary options use_shortcuts & use_filter_search to Copier Question, to allow the usage of numbered shortcuts for select questions, and filter search for select/checkbox questions.

It addresses Issue #2155

It adds two new attributes to the Question class:

  • use_shortcuts: bool
  • use_filter_search: bool

Which are passed down to questionary, thus producing the desired effect.

Note

use_filter_search is not compatible with use_jk_keys questionary option which is activated by default. Thus, when use_filter_search is True for a select/checkbox question, we pass and put use_jk_keys to False.

Choice

Although, it is possible to pass both {use_filter_search: True, use_shortcuts: True} for select questions, I have decided to make use_filter_search have precedence over use_shortcuts to avoid any ambiguity. Thus, if both are True for a select question, only use_filter_search is passed and activated.

Usage

use_shortcuts

copier.yaml

 select:
     type: str
     help: Select one option only
     use_shortcuts: true
     default: first
     choices:
         one: first
         two: second
         three: third

This will display:

1) one
2) two
3) three

And pressing 3 will direct the cursor onto 3) three .

use_filter_search

copier.yaml

checkbox:
    type: str
    help: Select any
    multiselect: true
    use_filter_search: true
    choices:
        one: first
        two: second
        three: third
        four: fourth

Pressing t will make a field appeared at the bottom left with / t... while only showing:

o two
o three

Next, pressing w will change the bottom field to / tw.. while only showing:

o two

Pressing Backspace will delete the searched letter one at a time, allowing for correction or new/additional filter search.

Limitation

The two new attributes are only booleans, they are not str|bool, and not rendered.

@codecov
Copy link

codecov bot commented Jan 8, 2026

Codecov Report

❌ Patch coverage is 84.84848% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.20%. Comparing base (6b5939d) to head (4ba04f7).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
copier/_user_data.py 28.57% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2446      +/-   ##
==========================================
- Coverage   97.26%   97.20%   -0.06%     
==========================================
  Files          55       56       +1     
  Lines        6288     6332      +44     
==========================================
+ Hits         6116     6155      +39     
- Misses        172      177       +5     
Flag Coverage Δ
unittests 97.20% <84.84%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Member

@sisp sisp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for contributing these great enhancements of choice questions, @RR5555! 🙏

I have a few inline remarks. And could you please document these settings in the docs?

RR5555 added a commit to RR5555/copier that referenced this pull request Jan 11, 2026
Per reviewer suggestion/request

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678784231
Per reviewer suggestion/request

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678784231
@RR5555 RR5555 force-pushed the question_filter_shortcut branch from f043dd4 to 08371bf Compare January 11, 2026 06:01
…_filter_search` to `use_search_filter`

Per reviewer catch to match `questionary` args as intended

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678785014
Per reviewer request

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678786515
Per reviewer suggestion/request
Remove development leftovers

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678777344 copier-org#2446#discussion_r2678779566
Per reviewer suggestion/request

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678780560
…_filter`

Per reviewer suggestion/request

Reviewed-by: sisp
Refs: copier-org#2446#pullrequestreview-3647014001
@RR5555
Copy link
Contributor Author

RR5555 commented Jan 11, 2026

Thank you for reviewing another of my PRs ^^

I had a first go at the documentation of the settings (docs(docs/configuring.md): add docs for use_shortcuts & use_search_filter[f8c695e]).
Please let me know what you think.

RR5555 and others added 2 commits January 12, 2026 19:25
…se_searcg_filter`

Per reviewer suggestion/request

Reviewed-by: sisp
Refs: copier-org#2446#discussion_r2678783429
Comment on lines +208 to +210
Condition that, if `True`, will use `use_shortcuts` in `select` question,
allowing for selection via automatically numbered shortcut. Will be
deactivated if `use_search_filter` is `True`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer describing only the behavior of the enabled feature and omitting a reference to the underlying implementation. Also, I'd prefer documenting mutual exclusiveness with multiselect (raising a validation error when combined) rather than silent deactivation.

Suggested change
Condition that, if `True`, will use `use_shortcuts` in `select` question,
allowing for selection via automatically numbered shortcut. Will be
deactivated if `use_search_filter` is `True`.
Condition that, if `True`, allows selecting choice question items via
number shortcuts. Mutually exclusive with `multiselect`.

Comment on lines +213 to +215
Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select`
question while deactivating `use_jk_keys`, allowing for selection via
filtering.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer describing only the behavior of the enabled feature and omitting a reference to the underlying implementation.

Suggested change
Condition that, if `True`, uses `use_search_filter` in `checkbox`/`select`
question while deactivating `use_jk_keys`, allowing for selection via
filtering.
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.

- **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_search_filter**: When set to `true`, . Also deactivates the use of `j`/`k`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description of the effect of the feature is missing after the comma.


!!! note

If `multiselect` is `true`, you cannot use `Space` in the search as this would actually just still select the option. If it is `false`, the `Space` character can be used in the search filter.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • If we enable the pymdownx.keys extension, we'll be able to use pretty keyboard keys rendering in the documentation. WDYT?
  • Adding a comma before the "as" because it's used for a causal conjunction. 🤓
  • Rephrasing "[...] actually just still [...]" a little. 🤓
  • And just a bit more simplification. 🤓
Suggested change
If `multiselect` is `true`, you cannot use `Space` in the search as this would actually just still select the option. If it is `false`, the `Space` character can be used in the search filter.
If `multiselect` is `true`, you cannot use ++space++ in the search, as this
would also select the choice item. If it is `false`, ++space++ can be used.

Comment on lines +246 to +249

!!! note

If `use_shortcuts` & `use_search_filter` are both `true`, then only `use_search_filter` is activated.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd remove this note and mention the mutual exclusiveness above.

Suggested change
!!! note
If `use_shortcuts` & `use_search_filter` are both `true`, then only `use_search_filter` is activated.

Comment on lines +259 to +269
- python
- node
- c
- c++
- rust
- zig
- asm
- a new language
- a good one
- an average one
- a not so good one
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we stick with the same choices as in the example above? I think the list is already long enough with those choices, and typing o will filter python and node to show two remaining choices. I think the example showing the search filters an and ago isn't necessary – people will get the idea – while making the documentation quite lengthy.


---

You can use `Backspace` to modify the search filter.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we enable the pymdownx.keys extension, we'll be able to use pretty keyboard keys rendering in the documentation. WDYT?

Suggested change
You can use `Backspace` to modify the search filter.
You can use ++backspace++ to modify the search filter.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might just be me, but I find these parametrized tests a bit difficult to read and immediately understand what each case is testing exactly.

I was thinking about something like this – more repetitive but simple to read and grasp (IMHO):

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"


@pytest.mark.skip(  # TODO: Remove decorator when implemented
    reason="Not implemented yet"
)
def test_multiselect_with_shortcuts_not_supported(
    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
                    multiselect: true
                    choices:
                        one: first
                        two: second
                        three: third
                    use_shortcuts: true
                """
            )
        }
    )
    tui = spawn(COPIER_PATH + ("copy", str(src), str(dst)))
    # TODO: Check that an appropriate validation error is shown


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_with_number_input(
    tmp_path_factory: pytest.TempPathFactory, spawn: Spawn
) -> None:
    """
    When search filter and shortcuts are enabled, numbers filter choices rather than
    selecting them.
    """
    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)))
    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_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']"

The test function test_multiselect_with_shortcuts_not_supported is currently skipped because

  • Copier currently doesn't raise a validation error when multiselect and use_shortcuts are combined, and
  • the check for this validation error is still missing in the test function.

WDYT?

Comment on lines +290 to +291


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kudos for the beautiful prompt visualization using HTML in Markdown including the Unicode icon for the question type! 🏆 This looks great! 🤩

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants