From f5269086d9dc36d68604350e25d8243a818424d3 Mon Sep 17 00:00:00 2001 From: AN Long Date: Sat, 21 Feb 2026 17:24:59 +0900 Subject: [PATCH] Introduce optional blessed backend --- README.md | 8 +++-- example/basic.py | 5 ++- example/disabled.py | 5 ++- example/multiselect.py | 5 ++- example/option.py | 5 ++- example/scroll.py | 4 ++- poetry.lock | 67 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 3 ++ setup.cfg | 3 ++ src/pick/__init__.py | 63 ++++++++++++++++++++++++++++------ src/pick/backend.py | 27 +++++++++++++++ src/pick/blessed_backend.py | 55 ++++++++++++++++++++++++++++++ src/pick/curses_backend.py | 41 +++++++++++++++++++++++ 13 files changed, 272 insertions(+), 19 deletions(-) create mode 100644 src/pick/backend.py create mode 100644 src/pick/blessed_backend.py create mode 100644 src/pick/curses_backend.py diff --git a/README.md b/README.md index 2504a1b..6e3e3fc 100644 --- a/README.md +++ b/README.md @@ -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 | | :--------------------: | :--------------------------: | @@ -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 diff --git a/example/basic.py b/example/basic.py index fad055d..773f77e 100644 --- a/example/basic.py +++ b/example/basic.py @@ -1,3 +1,5 @@ +import os + from pick import pick KEY_CTRL_C = 3 @@ -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}") diff --git a/example/disabled.py b/example/disabled.py index 26909a3..301608d 100644 --- a/example/disabled.py +++ b/example/disabled.py @@ -1,3 +1,5 @@ +import os + from pick import pick, Option @@ -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}") diff --git a/example/multiselect.py b/example/multiselect.py index 0b5fb4b..d8dd783 100644 --- a/example/multiselect.py +++ b/example/multiselect.py @@ -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) diff --git a/example/option.py b/example/option.py index 4cab29c..4f20c07 100644 --- a/example/option.py +++ b/example/option.py @@ -1,3 +1,5 @@ +import os + from pick import pick, Option title = "Please choose your favorite programming language: " @@ -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}") diff --git a/example/scroll.py b/example/scroll.py index 9792a37..67c7434 100644 --- a/example/scroll.py +++ b/example/scroll.py @@ -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) diff --git a/poetry.lock b/poetry.lock index af31b0c..45cd26e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,38 @@ # This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +[[package]] +name = "ansicon" +version = "1.89.0" +description = "Python wrapper for loading Jason Hood's ANSICON" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"blessed\" and platform_system == \"Windows\"" +files = [ + {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, + {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, +] + +[[package]] +name = "blessed" +version = "1.30.0" +description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"blessed\"" +files = [ + {file = "blessed-1.30.0-py3-none-any.whl", hash = "sha256:4061a9f10dd22798716c2548ba36385af6a29d856c897f367c6ccc927e0b3a5a"}, + {file = "blessed-1.30.0.tar.gz", hash = "sha256:4d547019d7b40fc5420ea2ba2bc180fdccc31d6715298e2b49ffa7b020d44667"}, +] + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +wcwidth = ">=0.6" + +[package.extras] +docs = ["Pillow", "Sphinx (>3)", "sphinx-paramlinks", "sphinx_rtd_theme", "sphinxcontrib-manpage"] + [[package]] name = "cfgv" version = "3.4.0" @@ -111,6 +144,22 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "jinxed" +version = "1.3.0" +description = "Jinxed Terminal Library" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"blessed\" and platform_system == \"Windows\"" +files = [ + {file = "jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5"}, + {file = "jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf"}, +] + +[package.dependencies] +ansicon = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "mypy" version = "1.14.1" @@ -425,6 +474,19 @@ typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\"" docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +[[package]] +name = "wcwidth" +version = "0.6.0" +description = "Measures the displayed width of unicode strings in a terminal" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"blessed\"" +files = [ + {file = "wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad"}, + {file = "wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159"}, +] + [[package]] name = "windows-curses" version = "2.4.1" @@ -452,7 +514,10 @@ files = [ {file = "windows_curses-2.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:4588213f7ef3b0c24c5cb9e309653d7a84c1792c707561e8b471d466ca79f2b8"}, ] +[extras] +blessed = ["blessed"] + [metadata] lock-version = "2.1" python-versions = ">=3.8" -content-hash = "bc3242caa51c7e9c6bf215fa64b12273b93919da15552d5b8667ae6fca965974" +content-hash = "d3170b1542c3bfc1640c50af3ab439b5e27f50659aa2b1839e950c44925225a4" diff --git a/pyproject.toml b/pyproject.toml index 1e73b3d..82b0d97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/setup.cfg b/setup.cfg index 36fc3b0..0158047 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 49d96db..148b478 100644 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -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 @@ -28,6 +42,7 @@ class Option: Position = namedtuple('Position', ['y', 'x']) + @dataclass class Picker(Generic[OPTION_T]): options: Sequence[OPTION_T] @@ -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: @@ -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() @@ -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) @@ -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 @@ -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( @@ -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, @@ -257,5 +299,6 @@ def pick( position, clear_screen, quit_keys, + backend, ) return picker.start() diff --git a/src/pick/backend.py b/src/pick/backend.py new file mode 100644 index 0000000..52c3f10 --- /dev/null +++ b/src/pick/backend.py @@ -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: ... diff --git a/src/pick/blessed_backend.py b/src/pick/blessed_backend.py new file mode 100644 index 0000000..db0e255 --- /dev/null +++ b/src/pick/blessed_backend.py @@ -0,0 +1,55 @@ +import contextlib +import curses +from typing import Optional, Tuple + +from .backend import Backend + + +class BlessedBackend(Backend): + """Backend that uses the blessed library (optional dependency).""" + + def __init__(self) -> None: + try: + import blessed + except ImportError: + raise ImportError( + "blessed is required for BlessedBackend. " + "Install with: pip install pick[blessed]" + ) + self._term = blessed.Terminal() + self._ctx: Optional[contextlib.ExitStack] = None + + def setup(self) -> None: + self._ctx = contextlib.ExitStack() + self._ctx.enter_context(self._term.fullscreen()) + self._ctx.enter_context(self._term.cbreak()) + self._ctx.enter_context(self._term.hidden_cursor()) + + def teardown(self) -> None: + if self._ctx is not None: + self._ctx.close() + self._ctx = None + + def clear(self) -> None: + print(self._term.home + self._term.clear, end='', flush=True) + + def getmaxyx(self) -> Tuple[int, int]: + return (self._term.height, self._term.width) + + def addnstr(self, y: int, x: int, s: str, n: int) -> None: + print(self._term.move_yx(y, x) + s[:n], end='', flush=True) + + def getch(self) -> int: + key = self._term.inkey() + if key.is_sequence: + mapping = { + 'KEY_UP': curses.KEY_UP, + 'KEY_DOWN': curses.KEY_DOWN, + 'KEY_RIGHT': curses.KEY_RIGHT, + 'KEY_ENTER': curses.KEY_ENTER, + } + return mapping.get(key.name, -1) + return ord(key) if key else -1 + + def refresh(self) -> None: + pass # blessed prints directly, no refresh needed diff --git a/src/pick/curses_backend.py b/src/pick/curses_backend.py new file mode 100644 index 0000000..98f3137 --- /dev/null +++ b/src/pick/curses_backend.py @@ -0,0 +1,41 @@ +import curses +from typing import Optional, Tuple + +from .backend import Backend + + +class CursesBackend(Backend): + """Backend that uses the curses standard library.""" + + def __init__(self, screen: Optional["curses._CursesWindow"] = None) -> None: + self._screen = screen + + def setup(self) -> None: + try: + curses.use_default_colors() + curses.curs_set(0) + except Exception: + curses.initscr() + + def teardown(self) -> None: + pass # curses.wrapper handles cleanup + + def clear(self) -> None: + assert self._screen is not None + self._screen.clear() + + def getmaxyx(self) -> Tuple[int, int]: + assert self._screen is not None + return self._screen.getmaxyx() + + def addnstr(self, y: int, x: int, s: str, n: int) -> None: + assert self._screen is not None + self._screen.addnstr(y, x, s, n) + + def getch(self) -> int: + assert self._screen is not None + return self._screen.getch() + + def refresh(self) -> None: + assert self._screen is not None + self._screen.refresh()