Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
[![image](https://github.com/aisk/pick/actions/workflows/ci.yml/badge.svg)](https://github.com/aisk/pick/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pick.svg)](https://pypi.python.org/pypi/pick)
[![PyPI](https://img.shields.io/pypi/dm/pick)](https://pypi.python.org/pypi/pick)
[![Python 3.8 - 3.13 support](https://img.shields.io/badge/Python-3.8_--_3.13-blue?logo=python&logoColor=ffd43b&labelColor=306998&color=ffe873)](https://docs.python.org/3/)
[![Python 3.8 - 3.14 support](https://img.shields.io/badge/Python-3.8_--_3.14-blue?logo=python&logoColor=ffd43b&labelColor=306998&color=ffe873)](https://docs.python.org/3/)

**pick** is a small python library to help you create curses based
interactive selection list in the terminal.
**pick** is a small python library to help you create interactive
selection list in the terminal.

| Basic | Multiselect |
| :--------------------: | :--------------------------: |
Expand Down Expand Up @@ -61,6 +61,8 @@ interactive selection list in the terminal.
- `position`: (optional), if you are using `pick` within an existing curses application use this to set the first position to write to. e.g., `position=pick.Position(y=1, x=1)`
- `quit_keys`: (optional), if you want to quit early, you can pass a key codes.
If the corresponding key are pressed, it will quit the menu.
- `backend`: (optional), the rendering backend to use. Accepts `"curses"` (default),
`"blessed"` (requires `pip install pick[blessed]`), or a custom `Backend` instance.

## Community Projects

Expand Down
5 changes: 4 additions & 1 deletion example/basic.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from pick import pick

KEY_CTRL_C = 3
Expand All @@ -7,6 +9,7 @@
title = "Please choose your favorite programming language: "
options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"]
option, index = pick(
options, title, indicator="=>", default_index=2, quit_keys=QUIT_KEYS
options, title, indicator="=>", default_index=2, quit_keys=QUIT_KEYS,
backend=os.environ.get("PICK_BACKEND", "curses"),
)
print(f"You chose {option} at index {index}")
5 changes: 4 additions & 1 deletion example/disabled.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from pick import pick, Option


Expand All @@ -8,5 +10,6 @@
Option("Option 3", description="This option is disabled!", enabled=False),
Option("Option 4", description="Moving up and down, skips over the disabled options.")
]
option, index = pick(options, title, indicator="=>")
option, index = pick(options, title, indicator="=>",
backend=os.environ.get("PICK_BACKEND", "curses"))
print(f"You chose {option} at index {index}")
5 changes: 4 additions & 1 deletion example/multiselect.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import os

from pick import pick

title = "Choose your favorite programming language(use space to select)"
options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"]
selected = pick(options, title, multiselect=True, min_selection_count=1)
selected = pick(options, title, multiselect=True, min_selection_count=1,
backend=os.environ.get("PICK_BACKEND", "curses"))
print(selected)
5 changes: 4 additions & 1 deletion example/option.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import os

from pick import pick, Option

title = "Please choose your favorite programming language: "
Expand All @@ -7,5 +9,6 @@
Option("JavaScript", ".js"),
Option("C++")
]
option, index = pick(options, title, indicator="=>")
option, index = pick(options, title, indicator="=>",
backend=os.environ.get("PICK_BACKEND", "curses"))
print(f"You chose {option} at index {index}")
4 changes: 3 additions & 1 deletion example/scroll.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import os

from pick import pick

title = "Select:"
options = ["foo.bar%s.baz" % x for x in range(1, 71)]
option, index = pick(options, title)
option, index = pick(options, title, backend=os.environ.get("PICK_BACKEND", "curses"))
print(option, index)
67 changes: 66 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ dependencies = [
"windows-curses>=2.2.0; platform_system=='Windows'",
]

[project.optional-dependencies]
blessed = ["blessed>=1.17.0"]

[dependency-groups]
dev = [
"pytest>=8.3.5",
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ check_untyped_defs = True
warn_return_any = True
warn_unreachable = True
warn_unused_ignores = True

[mypy-blessed]
ignore_missing_imports = True
63 changes: 53 additions & 10 deletions src/pick/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,21 @@
from dataclasses import dataclass, field
from typing import Any, Container, Generic, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union

__all__ = ["Picker", "pick", "Option"]
from .backend import Backend
from .blessed_backend import BlessedBackend
from .curses_backend import CursesBackend

__all__ = [
"Picker",
"pick",
"Option",
"Position",
"Backend",
"CursesBackend",
"BlessedBackend",
"SYMBOL_CIRCLE_FILLED",
"SYMBOL_CIRCLE_EMPTY",
]


@dataclass
Expand All @@ -28,6 +42,7 @@ class Option:

Position = namedtuple('Position', ['y', 'x'])


@dataclass
class Picker(Generic[OPTION_T]):
options: Sequence[OPTION_T]
Expand All @@ -42,6 +57,7 @@ class Picker(Generic[OPTION_T]):
position: Position = Position(0, 0)
clear_screen: bool = True
quit_keys: Optional[Union[Container[int], Iterable[int]]] = None
backend: Union[str, Backend] = "curses"

def __post_init__(self) -> None:
if len(self.options) == 0:
Expand Down Expand Up @@ -140,8 +156,8 @@ def get_lines(self, *, max_width: int = 80) -> Tuple[List[str], int]:
current_line = self.index + len(title_lines) + 1
return lines, current_line

def draw(self, screen: "curses._CursesWindow") -> None:
"""draw the curses ui on the screen, handle scroll if needed"""
def draw(self, screen: Backend) -> None:
"""draw the UI on the screen, handle scroll if needed"""
if self.clear_screen:
screen.clear()

Expand Down Expand Up @@ -184,7 +200,7 @@ def draw(self, screen: "curses._CursesWindow") -> None:
screen.refresh()

def run_loop(
self, screen: "curses._CursesWindow", position: Position
self, screen: Backend, position: Position
) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]:
while True:
self.draw(screen)
Expand All @@ -208,6 +224,18 @@ def run_loop(
elif c in KEYS_SELECT and self.multiselect:
self.mark_index()

def _resolve_backend(self) -> Backend:
if isinstance(self.backend, Backend):
return self.backend
if self.backend == "curses":
return CursesBackend(screen=self.screen)
if self.backend == "blessed":
return BlessedBackend()
raise ValueError(
f"Unknown backend: {self.backend!r}. "
"Use 'curses', 'blessed', or a Backend instance."
)

def config_curses(self) -> None:
try:
# use the default colors of the terminal
Expand All @@ -220,18 +248,31 @@ def config_curses(self) -> None:

def _start(self, screen: "curses._CursesWindow"):
self.config_curses()
return self.run_loop(screen, self.position)
return self.run_loop(CursesBackend(screen=screen), self.position)

def start(self):
if self.screen:
# Given an existing screen
# don't make any lasting changes
backend = self._resolve_backend()
if isinstance(backend, CursesBackend) and backend._screen is not None:
# Embedded in an existing curses application (backward-compatible)
last_cur = curses.curs_set(0)
ret = self.run_loop(self.screen, self.position)
ret = self.run_loop(backend, self.position)
if last_cur:
curses.curs_set(last_cur)
return ret
return curses.wrapper(self._start)
elif isinstance(backend, CursesBackend):
# Standalone curses mode
def _curses_main(screen: "curses._CursesWindow"):
backend._screen = screen
backend.setup()
return self.run_loop(backend, self.position)
return curses.wrapper(_curses_main)
else:
# Other backends (e.g. blessed)
backend.setup()
try:
return self.run_loop(backend, self.position)
finally:
backend.teardown()


def pick(
Expand All @@ -245,6 +286,7 @@ def pick(
position: Position = Position(0, 0),
clear_screen: bool = True,
quit_keys: Optional[Union[Container[int], Iterable[int]]] = None,
backend: Union[str, Backend] = "curses",
):
picker: Picker = Picker(
options,
Expand All @@ -257,5 +299,6 @@ def pick(
position,
clear_screen,
quit_keys,
backend,
)
return picker.start()
27 changes: 27 additions & 0 deletions src/pick/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from abc import ABC, abstractmethod
from typing import Tuple


class Backend(ABC):
"""Abstract base class for pick UI backends."""

@abstractmethod
def setup(self) -> None: ...

@abstractmethod
def teardown(self) -> None: ...

@abstractmethod
def clear(self) -> None: ...

@abstractmethod
def getmaxyx(self) -> Tuple[int, int]: ...

@abstractmethod
def addnstr(self, y: int, x: int, s: str, n: int) -> None: ...

@abstractmethod
def getch(self) -> int: ...

@abstractmethod
def refresh(self) -> None: ...
Loading
Loading