Skip to content

Add Navigable Choice Menu within click.prompt() #2795

Description

@D4ario0

A Navigable menu of options provided by the user, to select arguments from a list, leveraging click.readchar() function. This feature is inspired by tools like PyInquirer/Questionary and TUIs like lazygit UX to allow users an entry point to cli app rather than typing arguments.

The feature use case is similar prompt but instead of typing, navigating a list. This could reduce typos errors.

My personal use case is to request all valid files (file with a certain extension) from a path, and make the user choose the file e.g.

click.prompter("Choose a file you want to convert: ", [file for file in path.iterdir() if file.suffix == ".pdf"])

This is a small demo of the selector:

20241031180853.mp4

My current implementation, where prompter is the entrypoint:

from typing import List, Optional

from click import echo, getchar, secho, style

class Colors:
    RED = "red"
    YELLOW = "yellow"
    GREEN = "green"
    WHITE = "white"


class Cursors:
    SELECTED = "→"
    NON_SELECTED = " "
    EXIT_CURSOR = "✕"
    CHECKMARK = "✓"


# Terminal control sequences
CURSOR_UP = "\033[1A"  # Move cursor up and to start of the line
CLEAR_LINE = "\033[K"  # Clears the line


# Helper Text
EXIT_TEXT = "Quit (q)"
HELP_TEXT = "[↑/k] up | [↓/j] down | [enter] select | [q] quit"


def prompter(prompt: str, choices: list[str]) -> Optional[str]:
    """
    Display an interactive prompt with selectable options.

    Args:
        prompt: Text to display above the options
        choices: List of options to choose from
    """
    if not choices:
        raise ValueError("Options list must contain at least one item.")

    if not all(choice != "" for choice in choices):
        raise ValueError("Options list cannot be empty strings")

    secho(prompt, fg=Colors.YELLOW)

    selection = _select_option(choices)

    # Reprints the selected option next to the prompt
    _clear_lines()
    secho(prompt, fg=Colors.YELLOW, nl=False)
    secho(selection, fg=Colors.GREEN, bold=True)

    return selection


def _clear_lines(n_lines: int = 0) -> None:
    """Clears the terminals n lines up"""
    clear_text = f"{CURSOR_UP}{CLEAR_LINE}" * (n_lines + 1)  # +1 for help text
    echo(clear_text, nl=False)


def _update_menu(options: List[str], current_row: int) -> str:
    """
    Updates the current menu to be displayed according to the cursor position.
    """
    display_text = ""

    for i, option in enumerate(options):
        # Highlight selected row with appropriate colors
        if i == current_row:
            prefix = Cursors.EXIT_CURSOR if option == EXIT_TEXT else Cursors.SELECTED
            sel_color = Colors.RED if option == EXIT_TEXT else Colors.GREEN
            display_text += style(f"{prefix} {option}\n", fg=sel_color, bold=True)
        else:
            display_text += style(f"{Cursors.NON_SELECTED} {option}\n", fg=Colors.WHITE)

    # Display help text
    display_text += style(HELP_TEXT, fg=Colors.YELLOW, dim=True)
    return display_text
    # Highlight selected row with green [selected], red [exit] or white [non-selected]


def _select_option(options: list[str]) -> Optional[str]:
    """
    Handle user keydown presses for navigating options.
    """
    # Create a copy of the list with the quit option
    menu_options = options + [EXIT_TEXT]
    current_row = 0

    # Initial display
    echo(_update_menu(menu_options, current_row))

    try:
        while True:
            ch = getchar()

            match ch:
                # WIN ARROW UP KEY | UNIX ARROW UP KEY | VIM UP KEY
                case "\xe0H" | "\x1b[A" | "k":
                    current_row = max(current_row - 1, 0)
                    current_menu = _update_menu(menu_options, current_row)
                    _clear_lines(len(menu_options))
                    echo(current_menu)

                # WIN ARROW DOWN KEY | UNIX ARROW DOWN KEY | VIM DOWN KEY
                case "\xe0P" | "\x1b[B" | "j":
                    current_row = min(current_row + 1, len(menu_options) - 1)
                    current_menu = _update_menu(menu_options, current_row)
                    _clear_lines(len(menu_options))
                    echo(current_menu)

                # ENTER KEY
                case "\r" | "\n":
                    selected = menu_options[current_row]
                    if selected == EXIT_TEXT:
                        selected = None
                    break

                # EXIT
                case "q":
                    selected = None
                    break

    # Handle Ctrl+C exit gracefully
    except KeyboardInterrupt:
        selected = None

    _clear_lines(len(menu_options))

    return selected

Metadata

Metadata

Assignees

No one assigned

    Labels

    promptInteractive input and confirmation

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions