From 7dabbb3a1ba2b86dde547672f6896089f06a6fe1 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:44:37 -0400 Subject: [PATCH 01/21] feat: Major rework to blessed --- pyproject.toml | 4 +- src/pick/new.py | 167 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100755 src/pick/new.py diff --git a/pyproject.toml b/pyproject.toml index 916f3d6..aa43aec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pick" -version = "2.4.0" +version = "3.0.0" description = "Pick an option in the terminal with a simple GUI" authors = ["wong2 ", "AN Long "] license = "MIT" @@ -11,7 +11,7 @@ keywords = ["terminal", "gui"] [tool.poetry.dependencies] python = ">=3.7" -windows-curses = {version = "^2.2.0", platform = "win32"} +blessed = "1.21.0" [tool.poetry.dev-dependencies] pytest = "^7.2.0" diff --git a/src/pick/new.py b/src/pick/new.py new file mode 100755 index 0000000..da71a92 --- /dev/null +++ b/src/pick/new.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python + +from collections import namedtuple +from dataclasses import dataclass +from typing import Any, Container, Iterable, Optional, Sequence, Tuple, TypeVar, Union +import blessed + +__all__ = ["pick", "Option"] + +SYMBOL_CIRCLE_FILLED = "(x)" +SYMBOL_CIRCLE_EMPTY = "( )" + + +@dataclass +class Option: + label: str + value: Any = None + description: Optional[str] = None + enabled: bool = True + + def __str__(self) -> str: + return ( + f"{self.label}{' (' + self.description + ')' if self.description else ''}" + ) + + +OPTION_T = TypeVar("OPTION_T", str, Option) +PICK_RETURN_T = Tuple[OPTION_T, int] + +Position = namedtuple("Position", ["y", "x"]) + + +def _display_screen( + term: blessed.Terminal, + indicator: str, + title: Optional[str], + choices: Sequence[OPTION_T], + selection_idx: int, + selected: list[int], + multiselect: bool, +): + if title: + print(title) + + for idx, val in enumerate(choices): + selectable = "" + if isinstance(val, Option) and not val.enabled: + selectable = term.gray35 + + is_selected = "" + if multiselect: + is_selected = ( + f"{SYMBOL_CIRCLE_EMPTY} " + if idx not in selected + else f"{SYMBOL_CIRCLE_FILLED} " + ) + + if idx == selection_idx: + print(f"{indicator} {selectable}{is_selected}{val}{term.normal}") + else: + print( + f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val}{term.normal}" + ) + + +def _select( + options: Sequence[OPTION_T], + term: blessed.Terminal, + indicator: str, + title: Optional[str], + choices: Sequence[OPTION_T], + selection_idx: int, + selected: set[int], + multiselect: bool, + quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, +): + if not quit_keys: + quit_keys = [] + else: + quit_keys = [chr(key_code) for key_code in quit_keys] + + with term.fullscreen(), term.cbreak(): + print(term.clear()) + _display_screen( + term, indicator, title, options, selection_idx, selected, multiselect + ) + + selection_inprogress = True + while selection_inprogress: + key = term.inkey() + if key.is_sequence or key == " ": + if key.name in {"KEY_TAB", "KEY_DOWN"}: + selection_idx += 1 + elif key.name == "KEY_UP": + selection_idx -= 1 + elif key == " " and multiselect: + item = options[selection_idx] + if (isinstance(item, Option) and item.enabled) or not isinstance( + item, Option + ): + if selection_idx not in selected: + selected.add(selection_idx) + else: + selected.remove(selection_idx) + elif key.name == "KEY_ENTER": + if len(selected) == 0 and multiselect: + print("Must select at least one entry!") + else: + selected.add(selection_idx) + selection_inprogress = False + else: + if key.lower() in quit_keys: + selection_inprogress = False + + selection_idx = selection_idx % len(options) + print(term.clear()) + _display_screen( + term, indicator, title, options, selection_idx, selected, multiselect + ) + + return [options[idx] for idx in selected] + + +def pick( + options: Sequence[OPTION_T], + title: Optional[str] = None, + indicator: str = "*", + default_index: int = 0, + multiselect: bool = False, + min_selection_count: int = 0, + position: Position = Position(0, 0), + clear_screen: bool = True, + quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, +): + term = blessed.Terminal() + picked = None + + with term.fullscreen(), term.cbreak(): + picked = _select( + options, term, indicator, title, options, 0, set(), multiselect, quit_keys + ) + + if multiselect: + return picked + else: + return picked[0] + + +pick( + [ + Option("Option 1", "option 1", "this is option 1", enabled=False), + "option 2", + "option 3", + ], + "(Up/down/tab to move; space to select/de-select; Enter to continue)", + indicator="=>", + multiselect=True, + quit_keys=[ord("q")], +) + +pick( + ["Choice1", "choice 2", "choice3"], + "(Up/down/tab to move; Enter to select)", + indicator="=>", + multiselect=False, + quit_keys=[ord("q")], +) From 112d0e4fc94c8b5b569b89aa2c5b8ed67b11b3ca Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:40:21 -0400 Subject: [PATCH 02/21] fix: abide by min_selection_count --- src/pick/new.py | 88 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/src/pick/new.py b/src/pick/new.py index da71a92..e7e8c68 100755 --- a/src/pick/new.py +++ b/src/pick/new.py @@ -73,12 +73,13 @@ def _select( selected: set[int], multiselect: bool, quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, + min_selection_count: int = 0, ): if not quit_keys: quit_keys = [] else: quit_keys = [chr(key_code) for key_code in quit_keys] - + errmsg = "" with term.fullscreen(), term.cbreak(): print(term.clear()) _display_screen( @@ -103,8 +104,11 @@ def _select( else: selected.remove(selection_idx) elif key.name == "KEY_ENTER": - if len(selected) == 0 and multiselect: - print("Must select at least one entry!") + if multiselect: + if len(selected) < min_selection_count: + errmsg = f"{term.red}Must select at least {min_selection_count} entry(s)!{term.normal}" + else: + selection_inprogress = False else: selected.add(selection_idx) selection_inprogress = False @@ -113,7 +117,13 @@ def _select( selection_inprogress = False selection_idx = selection_idx % len(options) + print(term.clear()) + + if errmsg: + print(errmsg) + errmsg = "" + _display_screen( term, indicator, title, options, selection_idx, selected, multiselect ) @@ -137,31 +147,55 @@ def pick( with term.fullscreen(), term.cbreak(): picked = _select( - options, term, indicator, title, options, 0, set(), multiselect, quit_keys + options, + term, + indicator, + title, + options, + 0, + set(), + multiselect, + quit_keys, + min_selection_count, ) - if multiselect: - return picked - else: - return picked[0] - - -pick( - [ - Option("Option 1", "option 1", "this is option 1", enabled=False), - "option 2", - "option 3", - ], - "(Up/down/tab to move; space to select/de-select; Enter to continue)", - indicator="=>", - multiselect=True, - quit_keys=[ord("q")], + return picked if multiselect else (picked[0] if picked else None) + + +print( + "Picked: ", + pick( + [ + Option( + "Option 1", + "option 1", + "this is option 1 and is not selectable", + enabled=False, + ), + "option 2", + "option 3", + Option( + "Option 4", + "option 4", + "this is option 4 and selectable", + enabled=True, + ), + ], + "(Up/down/tab to move; space to select/de-select; Enter to continue)", + indicator="=>", + multiselect=True, + quit_keys=[ord("q")], + ), ) - -pick( - ["Choice1", "choice 2", "choice3"], - "(Up/down/tab to move; Enter to select)", - indicator="=>", - multiselect=False, - quit_keys=[ord("q")], +print() + +print( + "Picked: ", + pick( + ["Choice1", "choice 2", "choice3"], + "(Up/down/tab to move; Enter to select)", + indicator="=>", + multiselect=False, + quit_keys=[ord("q")], + ), ) From d59c2f06483e0d302383f20a1badb26725b5dc08 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:54:04 -0400 Subject: [PATCH 03/21] fix: more inline with source; update poetry.lock --- poetry.lock | 293 ++++++++++++++++++---------------- pyproject.toml | 2 +- src/pick/__init__.py | 365 ++++++++++++++++++++++++------------------- src/pick/new.py | 201 ------------------------ 4 files changed, 364 insertions(+), 497 deletions(-) mode change 100644 => 100755 src/pick/__init__.py delete mode 100755 src/pick/new.py diff --git a/poetry.lock b/poetry.lock index 5e7ca03..dc2ebfb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,33 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.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 = false +python-versions = "*" +groups = ["main"] +markers = "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.21.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 = false +python-versions = ">=2.7" +groups = ["main"] +files = [ + {file = "blessed-1.21.0-py2.py3-none-any.whl", hash = "sha256:f831e847396f5a2eac6c106f4dfadedf46c4f804733574b15fe86d2ed45a9588"}, + {file = "blessed-1.21.0.tar.gz", hash = "sha256:ece8bbc4758ab9176452f4e3a719d70088eb5739798cd5582c9e05f2a28337ec"}, +] + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +wcwidth = ">=0.1.4" [[package]] name = "cfgv" @@ -6,6 +35,7 @@ version = "3.3.1" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.6.1" +groups = ["dev"] files = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, @@ -13,13 +43,15 @@ files = [ [[package]] name = "colorama" -version = "0.4.5" +version = "0.4.6" description = "Cross-platform colored terminal text." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ - {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, - {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] @@ -28,6 +60,7 @@ version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, @@ -35,13 +68,15 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -53,6 +88,7 @@ version = "3.12.2" description = "A platform independent file lock." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, @@ -64,13 +100,14 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "identify" -version = "2.5.2" +version = "2.5.24" description = "File identification library for Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "identify-2.5.2-py2.py3-none-any.whl", hash = "sha256:feaa9db2dc0ce333b453ce171c0cf1247bbfde2c55fc6bb785022d411a1b78b5"}, - {file = "identify-2.5.2.tar.gz", hash = "sha256:a3d4c096b384d50d5e6dc5bc8b9bc44f1f61cefebd750a7b3e9f939b53fb214d"}, + {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, + {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, ] [package.extras] @@ -82,6 +119,8 @@ version = "6.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.7\"" files = [ {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, @@ -94,25 +133,43 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] [[package]] name = "iniconfig" -version = "1.1.1" -description = "iniconfig: brain-dead simple config-ini parsing" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jinxed" +version = "1.3.0" +description = "Jinxed Terminal Library" optional = false python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" files = [ - {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, - {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, + {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.4.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"}, {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"}, @@ -160,6 +217,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -167,38 +225,35 @@ files = [ [[package]] name = "nodeenv" -version = "1.7.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] files = [ - {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, - {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" -version = "21.3" +version = "24.0" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" - [[package]] name = "platformdirs" version = "4.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, @@ -213,13 +268,14 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co [[package]] name = "pluggy" -version = "1.0.0" +version = "1.2.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, ] [package.dependencies] @@ -231,13 +287,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.20.0" +version = "2.21.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"}, - {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"}, + {file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"}, + {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] [package.dependencies] @@ -246,22 +303,7 @@ identify = ">=1.0.0" importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" - -[[package]] -name = "pyparsing" -version = "3.0.7" -description = "Python parsing module" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, - {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] +virtualenv = ">=20.10.0" [[package]] name = "pytest" @@ -269,6 +311,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -292,6 +335,7 @@ version = "6.0.1" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, @@ -346,75 +390,69 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] -[[package]] -name = "setuptools" -version = "68.0.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, - {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" -version = "1.2.3" +version = "2.0.1" description = "A lil' TOML parser" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ - {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, - {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] [[package]] name = "typed-ast" -version = "1.5.4" +version = "1.5.5" description = "a fork of Python 2 and 3 ast modules with type comment support" optional = false python-versions = ">=3.6" +groups = ["dev"] +markers = "python_version == \"3.7\"" files = [ - {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, - {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, - {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, - {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, - {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, - {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, - {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, - {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, - {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, - {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, + {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, + {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, + {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, + {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, + {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, + {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, + {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, + {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, + {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, + {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, + {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, + {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, + {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, + {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, + {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, + {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, + {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, + {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, + {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, + {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, + {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, + {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, + {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, + {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, + {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, + {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, + {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, + {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, + {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, + {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, + {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, + {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, + {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, + {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, + {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, + {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, + {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, + {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, + {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, + {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, + {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, ] [[package]] @@ -423,6 +461,7 @@ version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, @@ -434,6 +473,7 @@ version = "20.26.6" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, @@ -447,31 +487,18 @@ platformdirs = ">=3.9.1,<5" [package.extras] 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)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +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 == \"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 = "windows-curses" -version = "2.4.1" -description = "Support for the standard curses module on Windows" +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" +groups = ["main"] files = [ - {file = "windows_curses-2.4.1-cp310-cp310-win32.whl", hash = "sha256:53d711e07194d0d3ff7ceff29e0955b35479bc01465d46c3041de67b8141db2f"}, - {file = "windows_curses-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:325439cd4f37897a1de8a9c068a5b4c432f9244bf9c855ee2fbeb3fa721a770c"}, - {file = "windows_curses-2.4.1-cp311-cp311-win32.whl", hash = "sha256:4fa1a176bfcf098d0c9bb7bc03dce6e83a4257fc0c66ad721f5745ebf0c00746"}, - {file = "windows_curses-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fd7d7a9cf6c1758f46ed76b8c67f608bc5fcd5f0ca91f1580fd2d84cf41c7f4f"}, - {file = "windows_curses-2.4.1-cp312-cp312-win32.whl", hash = "sha256:bdbe7d58747408aef8a9128b2654acf6fbd11c821b91224b9a046faba8c6b6ca"}, - {file = "windows_curses-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c9c2635faf171a229caca80e1dd760ab00db078e2a285ba2f667bbfcc31777c"}, - {file = "windows_curses-2.4.1-cp313-cp313-win32.whl", hash = "sha256:05d1ca01e5199a435ccb6c8c2978df4a169cdff1ec99ab15f11ded9de8e5be26"}, - {file = "windows_curses-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8cf653f8928af19c103ae11cfed38124f418dcdd92643c4cd17239c0cec2f9da"}, - {file = "windows_curses-2.4.1-cp36-cp36m-win32.whl", hash = "sha256:6a5a831cabaadde41a6856fea5a0c68c74b7d11d332a816e5a5e6c84577aef3a"}, - {file = "windows_curses-2.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e61be805edc390ccfdeaf0e0c39736d931d3c4a007d6bf0f98d1e792ce437796"}, - {file = "windows_curses-2.4.1-cp37-cp37m-win32.whl", hash = "sha256:a36b8fd4e410ddfb1a8eb65af2116c588e9f99b2ff3404412317440106755485"}, - {file = "windows_curses-2.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:db776df70c10bd523c4a1ab0a7624a1d58c7d47f83ec49c6988f05bc1189e7b8"}, - {file = "windows_curses-2.4.1-cp38-cp38-win32.whl", hash = "sha256:e9ce84559f80de7ec770d28c3b2991e0da51748def04e25a3c08ada727cfac2d"}, - {file = "windows_curses-2.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:618e31458fedba2cf8105485ff00533ece780026c544142fc1647a20dc6c7641"}, - {file = "windows_curses-2.4.1-cp39-cp39-win32.whl", hash = "sha256:775a2e0fefeddfdb0e00b3fa6c4f21caf9982db34df30e4e62c49caaef7b5e56"}, - {file = "windows_curses-2.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:4588213f7ef3b0c24c5cb9e309653d7a84c1792c707561e8b471d466ca79f2b8"}, + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] [[package]] @@ -480,6 +507,8 @@ version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.7\"" files = [ {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, @@ -487,9 +516,9 @@ files = [ [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.7" -content-hash = "472e03e521bc3721394fdb564964acacf478672a3f1c412394961b629d0a3bfe" +content-hash = "fa9efc634d69304cb87a1ce685dbb2c20d49cf25fb54a25ca386abd1ffcef9b3" diff --git a/pyproject.toml b/pyproject.toml index aa43aec..aff0ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ keywords = ["terminal", "gui"] python = ">=3.7" blessed = "1.21.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^7.2.0" mypy = "^1.4" pre-commit = "^2.20.0" diff --git a/src/pick/__init__.py b/src/pick/__init__.py old mode 100644 new mode 100755 index 1c09843..a84a297 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -1,10 +1,24 @@ -import curses -import textwrap +#!/usr/bin/env python + from collections import namedtuple from dataclasses import dataclass, field -from typing import Any, Container, Generic, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union +from typing import ( + Any, + Generic, + Iterable, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, +) +import blessed + +__all__ = ["pick", "Picker", "Option"] -__all__ = ["Picker", "pick", "Option"] +SYMBOL_CIRCLE_FILLED = "(x)" +SYMBOL_CIRCLE_EMPTY = "( )" @dataclass @@ -14,35 +28,68 @@ class Option: description: Optional[str] = None enabled: bool = True + def __str__(self) -> str: + return ( + f"{self.label}{' (' + self.description + ')' if self.description else ''}" + ) -KEYS_ENTER = (curses.KEY_ENTER, ord("\n"), ord("\r")) -KEYS_UP = (curses.KEY_UP, ord("k")) -KEYS_DOWN = (curses.KEY_DOWN, ord("j")) -KEYS_SELECT = (curses.KEY_RIGHT, ord(" ")) -SYMBOL_CIRCLE_FILLED = "(x)" -SYMBOL_CIRCLE_EMPTY = "( )" - -OPTION_T = TypeVar("OPTION_T", str, Option) +OPTION_T = Union[Option, str] PICK_RETURN_T = Tuple[OPTION_T, int] -Position = namedtuple('Position', ['y', 'x']) +Position = namedtuple("Position", ["y", "x"]) + + +def _display_screen( + term: blessed.Terminal, + indicator: str, + title: Optional[str], + choices: Sequence[OPTION_T], + index: int, + selected_indexes: list[int], + multiselect: bool, +) -> None: + if title: + print(title) + + for idx, val in enumerate(choices): + selectable = "" + if isinstance(val, Option) and not val.enabled: + selectable = term.gray35 + + is_selected = "" + if multiselect: + is_selected = ( + f"{SYMBOL_CIRCLE_EMPTY} " + if idx not in selected_indexes + else f"{SYMBOL_CIRCLE_FILLED} " + ) + + if idx == index: + print(f"{indicator} {selectable}{is_selected}{val}{term.normal}") + else: + print( + f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val}{term.normal}" + ) + @dataclass -class Picker(Generic[OPTION_T]): +class Picker: options: Sequence[OPTION_T] title: Optional[str] = None indicator: str = "*" default_index: int = 0 multiselect: bool = False min_selection_count: int = 0 - selected_indexes: List[int] = field(init=False, default_factory=list) - index: int = field(init=False, default=0) - screen: Optional["curses._CursesWindow"] = None + selected_indexes: List[int] = field(init=True, default_factory=list) + index: int = field(init=True, default=0) position: Position = Position(0, 0) clear_screen: bool = True - quit_keys: Optional[Union[Container[int], Iterable[int]]] = None + quit_keys: Optional[Iterable[int]] = None + term: blessed.Terminal = None + # ) -> List[PICK_RETURN_T]: + # screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: if len(self.options) == 0: raise ValueError("options should not be an empty list") @@ -55,7 +102,9 @@ def __post_init__(self) -> None: "min_selection_count is bigger than the available options, you will not be able to make any selection" ) - if all(isinstance(option, Option) and not option.enabled for option in self.options): + if all( + isinstance(option, Option) and not option.enabled for option in self.options + ): raise ValueError( "all given options are disabled, you must at least have one enabled option." ) @@ -85,148 +134,89 @@ def move_down(self) -> None: def mark_index(self) -> None: if self.multiselect: - if self.index in self.selected_indexes: - self.selected_indexes.remove(self.index) - else: - self.selected_indexes.append(self.index) + item = self.options[self.index] + if (isinstance(item, Option) and item.enabled) or not isinstance( + item, Option + ): + if self.index in self.selected_indexes: + self.selected_indexes.remove(self.index) + else: + self.selected_indexes.append(self.index) def get_selected(self) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: - """return the current selected option as a tuple: (option, index) + """Return the current selected option as a tuple: (option, index) or as a list of tuples (in case multiselect==True) """ if self.multiselect: - return_tuples = [] - for selected in self.selected_indexes: - return_tuples.append((self.options[selected], selected)) - return return_tuples + return ( + [(self.options[idx], idx) for idx in self.selected_indexes] + if self.multiselect + else (self.options[self.index], self.index) + ) else: return self.options[self.index], self.index - def get_title_lines(self, *, max_width: int = 80) -> List[str]: - if self.title: - return textwrap.fill(self.title, max_width - 2, drop_whitespace=False).split("\n") + [""] - return [] - - def get_option_lines(self) -> List[str]: - lines: List[str] = [] - for index, option in enumerate(self.options): - if index == self.index: - prefix = self.indicator - else: - prefix = len(self.indicator) * " " - - if self.multiselect: - symbol = ( - SYMBOL_CIRCLE_FILLED - if index in self.selected_indexes - else SYMBOL_CIRCLE_EMPTY - ) - prefix = f"{prefix} {symbol}" - - option_as_str = option.label if isinstance(option, Option) else option - lines.append(f"{prefix} {option_as_str}") - - return lines - - def get_lines(self, *, max_width: int = 80) -> Tuple[List[str], int]: - title_lines = self.get_title_lines(max_width=max_width) - option_lines = self.get_option_lines() - lines = title_lines + option_lines - 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""" - if self.clear_screen: - screen.clear() - - y, x = self.position # start point - - max_y, max_x = screen.getmaxyx() - max_rows = max_y - y # the max rows we can draw - - lines, current_line = self.get_lines(max_width=max_x) - - # calculate how many lines we should scroll, relative to the top - scroll_top = 0 - if current_line > max_rows: - scroll_top = current_line - max_rows - - lines_to_draw = lines[scroll_top : scroll_top + max_rows] - - description_present = False - for option in self.options: - if isinstance(option, Option) and option.description is not None: - description_present = True - break - - title_length = len(self.get_title_lines(max_width=max_x)) + def start(self): + _quit_keys: list[str] = ( + [] + if self.quit_keys is None + else [chr(key_code) for key_code in self.quit_keys] + ) + errmsg = "" + + with self.term.fullscreen(), self.term.cbreak(), self.term.hidden_cursor(): + print(self.term.clear()) + _display_screen( + self.term, + self.indicator, + self.title, + self.options, + self.index, + self.selected_indexes, + self.multiselect, + ) - for i, line in enumerate(lines_to_draw): - if description_present and i > title_length: - screen.addnstr(y, x, line, max_x // 2 - 2) - else: - screen.addnstr(y, x, line, max_x - 2) - y += 1 + selection_inprogress = True + while selection_inprogress: + key = self.term.inkey() + if key.is_sequence or key == " ": + if key.name in {"KEY_TAB", "KEY_DOWN"}: + self.move_down() + elif key.name == "KEY_UP": + self.move_up() + elif key == " " and self.multiselect: + self.mark_index() + elif key.name == "KEY_ENTER": + if ( + self.multiselect + and len(self.selected_indexes) < self.min_selection_count + ): + errmsg = f"{self.term.red}Must select at least {self.min_selection_count} entry(s)!{self.term.normal}" + else: + return self.get_selected() + else: + if key.lower() in _quit_keys: + return None, -1 - option = self.options[self.index] - if isinstance(option, Option) and option.description is not None: - description_lines = textwrap.fill(option.description, max_x // 2 - 2).split('\n') + self.index = self.index % len(self.options) - for i, line in enumerate(description_lines): - screen.addnstr(i + title_length, max_x // 2, line, max_x - 2) + print(self.term.clear()) - screen.refresh() + if errmsg: + print(errmsg) + errmsg = "" - def run_loop( - self, screen: "curses._CursesWindow", position: Position - ) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: - while True: - self.draw(screen) - c = screen.getch() - if self.quit_keys is not None and c in self.quit_keys: - if self.multiselect: - return [] - else: - return None, -1 - elif c in KEYS_UP: - self.move_up() - elif c in KEYS_DOWN: - self.move_down() - elif c in KEYS_ENTER: - if ( - self.multiselect - and len(self.selected_indexes) < self.min_selection_count - ): - continue - return self.get_selected() - elif c in KEYS_SELECT and self.multiselect: - self.mark_index() - - def config_curses(self) -> None: - try: - # use the default colors of the terminal - curses.use_default_colors() - # hide the cursor - curses.curs_set(0) - except Exception: - # Curses failed to initialize color support, eg. when TERM=vt100 - curses.initscr() - - def _start(self, screen: "curses._CursesWindow"): - self.config_curses() - return self.run_loop(screen, self.position) + _display_screen( + self.term, + self.indicator, + self.title, + self.options, + self.index, + self.selected_indexes, + self.multiselect, + ) - def start(self): - if self.screen: - # Given an existing screen - # don't make any lasting changes - last_cur = curses.curs_set(0) - ret = self.run_loop(self.screen, self.position) - if last_cur: - curses.curs_set(last_cur) - return ret - return curses.wrapper(self._start) + return self.get_selected() def pick( @@ -236,21 +226,70 @@ def pick( default_index: int = 0, multiselect: bool = False, min_selection_count: int = 0, - screen: Optional["curses._CursesWindow"] = None, position: Position = Position(0, 0), clear_screen: bool = True, - quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, -): - picker: Picker = Picker( - options, - title, - indicator, - default_index, - multiselect, - min_selection_count, - screen, - position, - clear_screen, - quit_keys, - ) - return picker.start() + quit_keys: Optional[Iterable[int]] = None, +) -> Union[List[PICK_RETURN_T], Optional[PICK_RETURN_T]]: + term = blessed.Terminal() + picked = None + + with term.fullscreen(), term.cbreak(): + print(Picker(["a"], "a")) + print("???") + picked = Picker( + options=options, + title=title, + indicator=indicator, + default_index=default_index, + multiselect=multiselect, + min_selection_count=min_selection_count, + selected_indexes=[], + index=0, + clear_screen=clear_screen, + position=position, + quit_keys=quit_keys, + term=term, + ).start() + + return picked if multiselect else (picked[0] if picked else None) + + +print( + "Picked: ", + pick( + [ + Option( + "Option 1", + "option 1", + "this is option 1 and is not selectable", + enabled=False, + ), + "option 2", + "option 3", + Option( + "Option 4", + "option 4", + "this is option 4 and selectable", + enabled=True, + ), + ], + "(Up/down/tab to move; space to select/de-select; Enter to continue)", + indicator="=>", + multiselect=True, + quit_keys=[ord("q")], + clear_screen=False, + min_selection_count=2, + ), +) +print() + +print( + "Picked: ", + pick( + ["Choice1", "choice 2", "choice3"], + "(Up/down/tab to move; Enter to select)", + indicator="=>", + multiselect=False, + quit_keys=[ord("q")], + ), +) diff --git a/src/pick/new.py b/src/pick/new.py deleted file mode 100755 index e7e8c68..0000000 --- a/src/pick/new.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python - -from collections import namedtuple -from dataclasses import dataclass -from typing import Any, Container, Iterable, Optional, Sequence, Tuple, TypeVar, Union -import blessed - -__all__ = ["pick", "Option"] - -SYMBOL_CIRCLE_FILLED = "(x)" -SYMBOL_CIRCLE_EMPTY = "( )" - - -@dataclass -class Option: - label: str - value: Any = None - description: Optional[str] = None - enabled: bool = True - - def __str__(self) -> str: - return ( - f"{self.label}{' (' + self.description + ')' if self.description else ''}" - ) - - -OPTION_T = TypeVar("OPTION_T", str, Option) -PICK_RETURN_T = Tuple[OPTION_T, int] - -Position = namedtuple("Position", ["y", "x"]) - - -def _display_screen( - term: blessed.Terminal, - indicator: str, - title: Optional[str], - choices: Sequence[OPTION_T], - selection_idx: int, - selected: list[int], - multiselect: bool, -): - if title: - print(title) - - for idx, val in enumerate(choices): - selectable = "" - if isinstance(val, Option) and not val.enabled: - selectable = term.gray35 - - is_selected = "" - if multiselect: - is_selected = ( - f"{SYMBOL_CIRCLE_EMPTY} " - if idx not in selected - else f"{SYMBOL_CIRCLE_FILLED} " - ) - - if idx == selection_idx: - print(f"{indicator} {selectable}{is_selected}{val}{term.normal}") - else: - print( - f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val}{term.normal}" - ) - - -def _select( - options: Sequence[OPTION_T], - term: blessed.Terminal, - indicator: str, - title: Optional[str], - choices: Sequence[OPTION_T], - selection_idx: int, - selected: set[int], - multiselect: bool, - quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, - min_selection_count: int = 0, -): - if not quit_keys: - quit_keys = [] - else: - quit_keys = [chr(key_code) for key_code in quit_keys] - errmsg = "" - with term.fullscreen(), term.cbreak(): - print(term.clear()) - _display_screen( - term, indicator, title, options, selection_idx, selected, multiselect - ) - - selection_inprogress = True - while selection_inprogress: - key = term.inkey() - if key.is_sequence or key == " ": - if key.name in {"KEY_TAB", "KEY_DOWN"}: - selection_idx += 1 - elif key.name == "KEY_UP": - selection_idx -= 1 - elif key == " " and multiselect: - item = options[selection_idx] - if (isinstance(item, Option) and item.enabled) or not isinstance( - item, Option - ): - if selection_idx not in selected: - selected.add(selection_idx) - else: - selected.remove(selection_idx) - elif key.name == "KEY_ENTER": - if multiselect: - if len(selected) < min_selection_count: - errmsg = f"{term.red}Must select at least {min_selection_count} entry(s)!{term.normal}" - else: - selection_inprogress = False - else: - selected.add(selection_idx) - selection_inprogress = False - else: - if key.lower() in quit_keys: - selection_inprogress = False - - selection_idx = selection_idx % len(options) - - print(term.clear()) - - if errmsg: - print(errmsg) - errmsg = "" - - _display_screen( - term, indicator, title, options, selection_idx, selected, multiselect - ) - - return [options[idx] for idx in selected] - - -def pick( - options: Sequence[OPTION_T], - title: Optional[str] = None, - indicator: str = "*", - default_index: int = 0, - multiselect: bool = False, - min_selection_count: int = 0, - position: Position = Position(0, 0), - clear_screen: bool = True, - quit_keys: Optional[Union[Container[int], Iterable[int]]] = None, -): - term = blessed.Terminal() - picked = None - - with term.fullscreen(), term.cbreak(): - picked = _select( - options, - term, - indicator, - title, - options, - 0, - set(), - multiselect, - quit_keys, - min_selection_count, - ) - - return picked if multiselect else (picked[0] if picked else None) - - -print( - "Picked: ", - pick( - [ - Option( - "Option 1", - "option 1", - "this is option 1 and is not selectable", - enabled=False, - ), - "option 2", - "option 3", - Option( - "Option 4", - "option 4", - "this is option 4 and selectable", - enabled=True, - ), - ], - "(Up/down/tab to move; space to select/de-select; Enter to continue)", - indicator="=>", - multiselect=True, - quit_keys=[ord("q")], - ), -) -print() - -print( - "Picked: ", - pick( - ["Choice1", "choice 2", "choice3"], - "(Up/down/tab to move; Enter to select)", - indicator="=>", - multiselect=False, - quit_keys=[ord("q")], - ), -) From a18795f9ae6d2e880900fff985386f641795ec6c Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 14:47:30 -0400 Subject: [PATCH 04/21] fix: vertical scrolling --- example/basic.py | 2 +- example/position.py | 5 +- src/pick/__init__.py | 157 ++++++++++++++++++++++++++++--------------- tests/test_pick.py | 4 ++ 4 files changed, 111 insertions(+), 57 deletions(-) diff --git a/example/basic.py b/example/basic.py index fad055d..61dfe3e 100644 --- a/example/basic.py +++ b/example/basic.py @@ -5,7 +5,7 @@ QUIT_KEYS = (KEY_CTRL_C, KEY_ESCAPE, ord("q")) title = "Please choose your favorite programming language: " -options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] +options: list[str] = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] option, index = pick( options, title, indicator="=>", default_index=2, quit_keys=QUIT_KEYS ) diff --git a/example/position.py b/example/position.py index 34ca493..05aa855 100644 --- a/example/position.py +++ b/example/position.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import curses import pick @@ -6,7 +8,7 @@ def main(stdscr): stdscr.get_wch() y, x = stdscr.getyx() - + title = "Please choose your favorite programming language: " options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] option, index = pick.pick( @@ -14,7 +16,6 @@ def main(stdscr): title, indicator="=>", default_index=2, - screen=stdscr, position=pick.Position(y=y, x=0) # comment this to demonstrate the issue it solves ) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index a84a297..fcb84ac 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -12,6 +12,7 @@ Tuple, TypeVar, Union, + cast, ) import blessed @@ -35,7 +36,8 @@ def __str__(self) -> str: OPTION_T = Union[Option, str] -PICK_RETURN_T = Tuple[OPTION_T, int] +_pick_type = Tuple[Optional[OPTION_T], int] +PICK_RETURN_T = Union[List[_pick_type], _pick_type] Position = namedtuple("Position", ["y", "x"]) @@ -49,10 +51,19 @@ def _display_screen( selected_indexes: list[int], multiselect: bool, ) -> None: + # Chunk logic stuff is required to do scrolling when too many + # vertical items + chunk_by = term.height - 5 + chunked_choices = [ + choices[i : i + chunk_by] for i in range(0, len(choices), chunk_by) + ] + chunk_to_render = index // chunk_by + if title: print(title) - for idx, val in enumerate(choices): + for i, val in enumerate(chunked_choices[chunk_to_render]): + idx = i + (chunk_to_render * chunk_by) selectable = "" if isinstance(val, Option) and not val.enabled: selectable = term.gray35 @@ -86,8 +97,7 @@ class Picker: position: Position = Position(0, 0) clear_screen: bool = True quit_keys: Optional[Iterable[int]] = None - term: blessed.Terminal = None - # ) -> List[PICK_RETURN_T]: + term: Optional[blessed.Terminal] = None # screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: @@ -101,6 +111,9 @@ def __post_init__(self) -> None: raise ValueError( "min_selection_count is bigger than the available options, you will not be able to make any selection" ) + if self.term is None: + self.term = blessed.Terminal() + # raise ValueError("Must specify term=...; e,g from a prior term=blessed.Terminal()") if all( isinstance(option, Option) and not option.enabled for option in self.options @@ -143,7 +156,40 @@ def mark_index(self) -> None: else: self.selected_indexes.append(self.index) - def get_selected(self) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: + def get_title_lines(self) -> List[str]: + if self.title: + return [self.title, ""] + return [] + + def get_option_lines(self) -> List[str]: + lines: List[str] = [] + for index, option in enumerate(self.options): + if index == self.index: + prefix = self.indicator + else: + prefix = len(self.indicator) * " " + + if self.multiselect: + symbol = ( + SYMBOL_CIRCLE_FILLED + if index in self.selected_indexes + else SYMBOL_CIRCLE_EMPTY + ) + prefix = f"{prefix} {symbol}" + + option_as_str = option.label if isinstance(option, Option) else option + lines.append(f"{prefix} {option_as_str}") + + return lines + + def get_lines(self) -> Tuple[List[str], int]: + title_lines = self.get_title_lines() + option_lines = self.get_option_lines() + lines = title_lines + option_lines + current_line = self.index + len(title_lines) + 1 + return lines, current_line + + def get_selected(self) -> PICK_RETURN_T: """Return the current selected option as a tuple: (option, index) or as a list of tuples (in case multiselect==True) """ @@ -156,7 +202,8 @@ def get_selected(self) -> Union[List[PICK_RETURN_T], PICK_RETURN_T]: else: return self.options[self.index], self.index - def start(self): + def start(self) -> PICK_RETURN_T: + self.term = cast(blessed.Terminal, self.term) _quit_keys: list[str] = ( [] if self.quit_keys is None @@ -198,7 +245,7 @@ def start(self): if key.lower() in _quit_keys: return None, -1 - self.index = self.index % len(self.options) + # self.index = self.index % len(self.options) print(self.term.clear()) @@ -229,13 +276,14 @@ def pick( position: Position = Position(0, 0), clear_screen: bool = True, quit_keys: Optional[Iterable[int]] = None, -) -> Union[List[PICK_RETURN_T], Optional[PICK_RETURN_T]]: +) -> PICK_RETURN_T: term = blessed.Terminal() picked = None - - with term.fullscreen(), term.cbreak(): - print(Picker(["a"], "a")) - print("???") + with ( + term.fullscreen(), + term.cbreak(), + term.location(position.x, position.y), + ): # , term.container(height=20, scrollable=True): picked = Picker( options=options, title=title, @@ -251,45 +299,46 @@ def pick( term=term, ).start() - return picked if multiselect else (picked[0] if picked else None) - - -print( - "Picked: ", - pick( - [ - Option( - "Option 1", - "option 1", - "this is option 1 and is not selectable", - enabled=False, - ), - "option 2", - "option 3", - Option( - "Option 4", - "option 4", - "this is option 4 and selectable", - enabled=True, - ), - ], - "(Up/down/tab to move; space to select/de-select; Enter to continue)", - indicator="=>", - multiselect=True, - quit_keys=[ord("q")], - clear_screen=False, - min_selection_count=2, - ), -) -print() - -print( - "Picked: ", - pick( - ["Choice1", "choice 2", "choice3"], - "(Up/down/tab to move; Enter to select)", - indicator="=>", - multiselect=False, - quit_keys=[ord("q")], - ), -) + return picked + + +if __name__ == "__main__": + print( + "Picked: ", + pick( + [ + Option( + "Option 1", + "option 1", + "this is option 1 and is not selectable", + enabled=False, + ), + "option 2", + "option 3", + Option( + "Option 4", + "option 4", + "this is option 4 and selectable", + enabled=True, + ), + ], + "(Up/down/tab to move; space to select/de-select; Enter to continue)", + indicator="=>", + multiselect=True, + quit_keys=[ord("q")], + clear_screen=False, + min_selection_count=2, + ), + ) + print() + + print( + "Picked: ", + pick( + ["Choice1", "choice 2", "choice3"], + "(Up/down/tab to move; Enter to select)", + indicator="=>", + multiselect=False, + quit_keys=[ord("q")], + ), + ) diff --git a/tests/test_pick.py b/tests/test_pick.py index f76aac8..fc15c6f 100644 --- a/tests/test_pick.py +++ b/tests/test_pick.py @@ -1,3 +1,4 @@ +from typing import List from pick import Picker, Option @@ -64,8 +65,11 @@ def test_option(): picker.move_down() selected_options = picker.get_selected() for option in selected_options: + assert isinstance(option, tuple) assert isinstance(option[0], Option) option = selected_options[0] + assert isinstance(option, tuple) + assert isinstance(option[0], Option) assert option[0].label == "option1" assert option[0].value == 101 assert option[0].description == "description1" From 50785718c139ff578139fca4f47dfd2beb25cc99 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 15:35:45 -0400 Subject: [PATCH 05/21] feat: Add filtering via typing --- src/pick/__init__.py | 207 ++++++++++++++++++++++++++++++------------- 1 file changed, 145 insertions(+), 62 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index fcb84ac..de8434d 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -42,46 +42,59 @@ def __str__(self) -> str: Position = namedtuple("Position", ["y", "x"]) -def _display_screen( - term: blessed.Terminal, - indicator: str, - title: Optional[str], - choices: Sequence[OPTION_T], - index: int, - selected_indexes: list[int], - multiselect: bool, -) -> None: - # Chunk logic stuff is required to do scrolling when too many - # vertical items - chunk_by = term.height - 5 - chunked_choices = [ - choices[i : i + chunk_by] for i in range(0, len(choices), chunk_by) - ] - chunk_to_render = index // chunk_by - - if title: - print(title) - - for i, val in enumerate(chunked_choices[chunk_to_render]): - idx = i + (chunk_to_render * chunk_by) - selectable = "" - if isinstance(val, Option) and not val.enabled: - selectable = term.gray35 - - is_selected = "" - if multiselect: - is_selected = ( - f"{SYMBOL_CIRCLE_EMPTY} " - if idx not in selected_indexes - else f"{SYMBOL_CIRCLE_FILLED} " - ) - - if idx == index: - print(f"{indicator} {selectable}{is_selected}{val}{term.normal}") - else: - print( - f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val}{term.normal}" - ) +# def _display_screen( +# term: blessed.Terminal, +# indicator: str, +# title: Optional[str], +# choices: Sequence[OPTION_T], +# index: int, +# selected_indexes: list[int], +# multiselect: bool, +# current_filter: str, +# ) -> None: +# # Chunk logic stuff is required to do scrolling when too many +# # vertical items +# choices_with_idx = [c for c in enumerate(choices)] +# +# if current_filter: +# choices_with_idx = [ +# choice for choice in choices if str(choice[1]).startswith(current_filter) +# ] +# +# if not choices_with_idx: +# print( +# f"{term.red}No matching results, please press backspace to unfilter...{term.normal}" +# ) +# return +# +# chunk_by = term.height - 5 +# chunked_choices = [ +# choices_with_idx[i : i + chunk_by] for i in range(0, len(choices_with_idx), chunk_by) +# ] +# chunk_to_render = index // chunk_by +# +# if title: +# print(title) +# +# for i, val in enumerate(chunked_choices[chunk_to_render]): +# selectable = "" +# if isinstance(val[1], Option) and not val[1].enabled: +# selectable = term.gray35 +# +# is_selected = "" +# if multiselect: +# is_selected = ( +# f"{SYMBOL_CIRCLE_EMPTY} " +# if val[0] not in selected_indexes +# else f"{SYMBOL_CIRCLE_FILLED} " +# ) +# +# if val[0] == index: +# print(f"{indicator} {selectable}{is_selected}{val[1]}{term.normal}") +# else: +# print( +# f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val[1]}{term.normal}" +# ) @dataclass @@ -98,6 +111,8 @@ class Picker: clear_screen: bool = True quit_keys: Optional[Iterable[int]] = None term: Optional[blessed.Terminal] = None + idxes_in_scope: List[int] = field(init=False, default_factory=list) + filter: str = field(init=False, default_factory=str) # screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: @@ -113,7 +128,6 @@ def __post_init__(self) -> None: ) if self.term is None: self.term = blessed.Terminal() - # raise ValueError("Must specify term=...; e,g from a prior term=blessed.Terminal()") if all( isinstance(option, Option) and not option.enabled for option in self.options @@ -124,24 +138,43 @@ def __post_init__(self) -> None: self.index = self.default_index option = self.options[self.index] + # self.idxes_in_scope = list(range(self.term.height)) + self.idxes_in_scope = list(range(len(self.options))) + self.filter = "" if isinstance(option, Option) and not option.enabled: self.move_down() def move_up(self) -> None: + if not self.idxes_in_scope: + # user pressed up/down with a filter with no items; + # break out or else it will infinitely decrement index + return + while True: self.index -= 1 if self.index < 0: self.index = len(self.options) - 1 option = self.options[self.index] + if self.index not in self.idxes_in_scope: + continue if not isinstance(option, Option) or option.enabled: break def move_down(self) -> None: + if not self.idxes_in_scope: + # user pressed up/down with a filter with no items; + # break out or else it will infinitely decrement index + return + while True: self.index += 1 if self.index >= len(self.options): self.index = 0 option = self.options[self.index] + + if self.index not in self.idxes_in_scope: + continue + if not isinstance(option, Option) or option.enabled: break @@ -213,15 +246,7 @@ def start(self) -> PICK_RETURN_T: with self.term.fullscreen(), self.term.cbreak(), self.term.hidden_cursor(): print(self.term.clear()) - _display_screen( - self.term, - self.indicator, - self.title, - self.options, - self.index, - self.selected_indexes, - self.multiselect, - ) + self._display_screen() selection_inprogress = True while selection_inprogress: @@ -231,7 +256,11 @@ def start(self) -> PICK_RETURN_T: self.move_down() elif key.name == "KEY_UP": self.move_up() - elif key == " " and self.multiselect: + elif ( + key == " " + and self.multiselect + and len(self.idxes_in_scope) != 0 + ): self.mark_index() elif key.name == "KEY_ENTER": if ( @@ -241,11 +270,14 @@ def start(self) -> PICK_RETURN_T: errmsg = f"{self.term.red}Must select at least {self.min_selection_count} entry(s)!{self.term.normal}" else: return self.get_selected() + elif key.name == "KEY_BACKSPACE": + self.filter = self.filter[:-1] if self.filter else "" else: if key.lower() in _quit_keys: return None, -1 - - # self.index = self.index % len(self.options) + else: + # assume they want to be able to filter + self.filter += key print(self.term.clear()) @@ -253,18 +285,69 @@ def start(self) -> PICK_RETURN_T: print(errmsg) errmsg = "" - _display_screen( - self.term, - self.indicator, - self.title, - self.options, - self.index, - self.selected_indexes, - self.multiselect, - ) + if self.filter: + print( + f"Currently filtering by: {self.term.yellow}'{self.filter}...'{self.term.normal}" + ) + + self._display_screen() return self.get_selected() + def _display_screen(self) -> None: + # Chunk logic stuff is required to do scrolling when too many + # vertical items + choices_with_idx = [c for c in enumerate(self.options)] + + if self.filter: + choices_with_idx = [ + choice + for choice in choices_with_idx + if str(choice[1]).startswith(self.filter) + ] + + if not choices_with_idx: + self.idxes_in_scope = [] + print( + f"{self.term.red}No matching results, please press backspace to unfilter...{self.term.normal}" + ) + return + + chunk_by = self.term.height - 5 + chunked_choices = [ + choices_with_idx[i : i + chunk_by] + for i in range(0, len(choices_with_idx), chunk_by) + ] + chunk_to_render = self.index // chunk_by + + if self.title: + print(self.title) + to_show = chunked_choices[chunk_to_render] + + self.idxes_in_scope = [pair[0] for pair in to_show] + + for i, val in enumerate(chunked_choices[chunk_to_render]): + selectable = "" + if isinstance(val[1], Option) and not val[1].enabled: + selectable = self.term.gray35 + + is_selected = "" + if self.multiselect: + is_selected = ( + f"{SYMBOL_CIRCLE_EMPTY} " + if val[0] not in self.selected_indexes + else f"{SYMBOL_CIRCLE_FILLED} " + ) + + if val[0] == self.index: + print( + f"{self.indicator} {selectable}{is_selected}{val[1]}{self.term.normal}" + ) + else: + print( + f"{' ' * (len(self.indicator) + 1)}{selectable}{is_selected}{val[1]}{self.term.normal}" + ) + def pick( options: Sequence[OPTION_T], From a191b5ffbc464d8a1a4cb3a521af2e6f35e14c3a Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 15:36:26 -0400 Subject: [PATCH 06/21] fix: tests --- src/pick/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index de8434d..27d4c6b 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -295,6 +295,7 @@ def start(self) -> PICK_RETURN_T: return self.get_selected() def _display_screen(self) -> None: + self.term = cast(blessed.Terminal, self.term) # Chunk logic stuff is required to do scrolling when too many # vertical items choices_with_idx = [c for c in enumerate(self.options)] From d9564d9b68e3ee69569ade4529ddc5121bcf3cdb Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:32:19 -0400 Subject: [PATCH 07/21] fix: paging filter on first go --- src/pick/__init__.py | 108 ++++++++++++++----------------------------- 1 file changed, 35 insertions(+), 73 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 27d4c6b..c7b7838 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -4,13 +4,11 @@ from dataclasses import dataclass, field from typing import ( Any, - Generic, Iterable, List, Optional, Sequence, Tuple, - TypeVar, Union, cast, ) @@ -42,61 +40,6 @@ def __str__(self) -> str: Position = namedtuple("Position", ["y", "x"]) -# def _display_screen( -# term: blessed.Terminal, -# indicator: str, -# title: Optional[str], -# choices: Sequence[OPTION_T], -# index: int, -# selected_indexes: list[int], -# multiselect: bool, -# current_filter: str, -# ) -> None: -# # Chunk logic stuff is required to do scrolling when too many -# # vertical items -# choices_with_idx = [c for c in enumerate(choices)] -# -# if current_filter: -# choices_with_idx = [ -# choice for choice in choices if str(choice[1]).startswith(current_filter) -# ] -# -# if not choices_with_idx: -# print( -# f"{term.red}No matching results, please press backspace to unfilter...{term.normal}" -# ) -# return -# -# chunk_by = term.height - 5 -# chunked_choices = [ -# choices_with_idx[i : i + chunk_by] for i in range(0, len(choices_with_idx), chunk_by) -# ] -# chunk_to_render = index // chunk_by -# -# if title: -# print(title) -# -# for i, val in enumerate(chunked_choices[chunk_to_render]): -# selectable = "" -# if isinstance(val[1], Option) and not val[1].enabled: -# selectable = term.gray35 -# -# is_selected = "" -# if multiselect: -# is_selected = ( -# f"{SYMBOL_CIRCLE_EMPTY} " -# if val[0] not in selected_indexes -# else f"{SYMBOL_CIRCLE_FILLED} " -# ) -# -# if val[0] == index: -# print(f"{indicator} {selectable}{is_selected}{val[1]}{term.normal}") -# else: -# print( -# f"{' ' * (len(indicator) + 1)}{selectable}{is_selected}{val[1]}{term.normal}" -# ) - - @dataclass class Picker: options: Sequence[OPTION_T] @@ -138,7 +81,6 @@ def __post_init__(self) -> None: self.index = self.default_index option = self.options[self.index] - # self.idxes_in_scope = list(range(self.term.height)) self.idxes_in_scope = list(range(len(self.options))) self.filter = "" if isinstance(option, Option) and not option.enabled: @@ -276,7 +218,7 @@ def start(self) -> PICK_RETURN_T: if key.lower() in _quit_keys: return None, -1 else: - # assume they want to be able to filter + # Keystroke gets appended to the current filter self.filter += key print(self.term.clear()) @@ -296,38 +238,55 @@ def start(self) -> PICK_RETURN_T: def _display_screen(self) -> None: self.term = cast(blessed.Terminal, self.term) - # Chunk logic stuff is required to do scrolling when too many - # vertical items - choices_with_idx = [c for c in enumerate(self.options)] + # options_with_idx is used instead of just self.options because + # we need to be able to keep track of the original item's index + # in the case that we're filtering + options_with_idx = [c for c in enumerate(self.options)] if self.filter: - choices_with_idx = [ + options_with_idx = [ choice - for choice in choices_with_idx + for choice in options_with_idx if str(choice[1]).startswith(self.filter) ] - if not choices_with_idx: + if not options_with_idx: self.idxes_in_scope = [] print( f"{self.term.red}No matching results, please press backspace to unfilter...{self.term.normal}" ) return - chunk_by = self.term.height - 5 + # Chunk logic stuff is required to do scrolling when too many + # vertical items + chunk_by = self.term.height - 7 + chunked_choices = [ - choices_with_idx[i : i + chunk_by] - for i in range(0, len(choices_with_idx), chunk_by) + options_with_idx[i : i + chunk_by] + for i in range(0, len(options_with_idx), chunk_by) ] - chunk_to_render = self.index // chunk_by + + chunk_to_render = 0 + for i, chunk in enumerate(chunked_choices): + if self.index in [p[0] for p in chunk]: + chunk_to_render = i + + # need to set this so that when we're doing a command thats not + # modifying the filter (i.e up/down) we need to be able to tell + # whether the cursor is in scope + self.idxes_in_scope = [pair[0] for chunk in chunked_choices for pair in chunk] + + # can't do this bc the index's aren't guarunteed to be consecutive + # outside the first iteration: + # chunk_to_render = self.index // chunk_by if self.title: print(self.title) - to_show = chunked_choices[chunk_to_render] - self.idxes_in_scope = [pair[0] for pair in to_show] + if chunk_to_render > 0: + print(" ( scroll up to reveal previous entries )") - for i, val in enumerate(chunked_choices[chunk_to_render]): + for val in chunked_choices[chunk_to_render]: selectable = "" if isinstance(val[1], Option) and not val[1].enabled: selectable = self.term.gray35 @@ -349,6 +308,9 @@ def _display_screen(self) -> None: f"{' ' * (len(self.indicator) + 1)}{selectable}{is_selected}{val[1]}{self.term.normal}" ) + if chunk_to_render < len(chunked_choices) - 1: + print(f" ( scroll down to reveal additional entries )") + def pick( options: Sequence[OPTION_T], @@ -367,7 +329,7 @@ def pick( term.fullscreen(), term.cbreak(), term.location(position.x, position.y), - ): # , term.container(height=20, scrollable=True): + ): picked = Picker( options=options, title=title, From 573d96570680b96aaff262371cebae68b47e1031 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:33:12 -0400 Subject: [PATCH 08/21] chore: undo unneeded changes --- example/basic.py | 2 +- example/position.py | 2 -- src/pick/__init__.py | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/example/basic.py b/example/basic.py index 61dfe3e..fad055d 100644 --- a/example/basic.py +++ b/example/basic.py @@ -5,7 +5,7 @@ QUIT_KEYS = (KEY_CTRL_C, KEY_ESCAPE, ord("q")) title = "Please choose your favorite programming language: " -options: list[str] = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] +options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] option, index = pick( options, title, indicator="=>", default_index=2, quit_keys=QUIT_KEYS ) diff --git a/example/position.py b/example/position.py index 05aa855..23daa43 100644 --- a/example/position.py +++ b/example/position.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - import curses import pick diff --git a/src/pick/__init__.py b/src/pick/__init__.py index c7b7838..adcceaa 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - from collections import namedtuple from dataclasses import dataclass, field from typing import ( From 6244f9bacb9646f4e2fb993f08ba82eb9dd79141 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:31:23 -0400 Subject: [PATCH 09/21] fix: paging for long lines --- example/scroll.py | 2 +- src/pick/__init__.py | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/example/scroll.py b/example/scroll.py index 9792a37..73efca1 100644 --- a/example/scroll.py +++ b/example/scroll.py @@ -1,6 +1,6 @@ from pick import pick title = "Select:" -options = ["foo.bar%s.baz" % x for x in range(1, 71)] +options = [f"foo.bar{x}.baz" * 100 for x in range(1, 71)] option, index = pick(options, title) print(option, index) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index adcceaa..e802b68 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -1,5 +1,6 @@ from collections import namedtuple from dataclasses import dataclass, field +from functools import partial from typing import ( Any, Iterable, @@ -11,6 +12,7 @@ cast, ) import blessed +from math import ceil __all__ = ["pick", "Picker", "Option"] @@ -257,17 +259,37 @@ def _display_screen(self) -> None: # Chunk logic stuff is required to do scrolling when too many # vertical items - chunk_by = self.term.height - 7 + chunked_choices = [] + current_chunk = [] + so_far = 0 + chunk_to_render = 0 + page = 0 + for idx, pairing in enumerate(options_with_idx): + if isinstance(pairing[1], Option): + linesize = len(pairing[1].label) + else: + linesize = len(pairing[1]) + + # height of title, plus two pagination lines + pad_height = ceil(len(self.title) / self.term.width) + 3 + # pad a bit for unknown title lengh + lines_used = ceil((linesize + pad_height) / self.term.width) + so_far += lines_used + if so_far > (self.term.height - pad_height): + # need to roll over + chunked_choices.append(current_chunk) + current_chunk = [pairing] + so_far = lines_used + page += 1 + else: + current_chunk.append(pairing) - chunked_choices = [ - options_with_idx[i : i + chunk_by] - for i in range(0, len(options_with_idx), chunk_by) - ] + if idx == len(options_with_idx) - 1: + # at the end, need to add what we've built then drop out + chunked_choices.append(current_chunk) - chunk_to_render = 0 - for i, chunk in enumerate(chunked_choices): - if self.index in [p[0] for p in chunk]: - chunk_to_render = i + if self.index == pairing[0]: + chunk_to_render = page # need to set this so that when we're doing a command thats not # modifying the filter (i.e up/down) we need to be able to tell From be216d72aa2b06c8699a41fb1b07e957572d9250 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:42:21 -0400 Subject: [PATCH 10/21] fix: paging for long lines (2) --- src/pick/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index e802b68..61ad6ac 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -270,9 +270,8 @@ def _display_screen(self) -> None: else: linesize = len(pairing[1]) - # height of title, plus two pagination lines - pad_height = ceil(len(self.title) / self.term.width) + 3 - # pad a bit for unknown title lengh + # height of title, plus two pagination lines, plus some extra room: + pad_height = ceil(len(self.title) / self.term.width) + 2 + 10 lines_used = ceil((linesize + pad_height) / self.term.width) so_far += lines_used if so_far > (self.term.height - pad_height): From 97569c960788b1a0bf3f9ff5c0cd98b23a17ca1e Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:13:28 -0400 Subject: [PATCH 11/21] feat: Add coloring options and doc --- README.md | 2 ++ example/scroll.py | 1 + src/pick/__init__.py | 17 ++++++++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0612fed..cc0028e 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,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. +- `disabled_color`: (optional) if you want to change the color that disabled options are; by default, is grey. Accepts a string like ANSI code. e.g. to make them bold red: `disabled_color='\x1b[1;31m'`; can also use codes from blessed like `disabled_color=Terminal().yellow`; to make it not-colored, `disabled_color=""`) +- `pagination_color`: (optional) if you want to change the color that the pagination messages (shown when there is multiple pages of content to scroll through); by default, is uncolored. Accepts a string like ANSI code. e.g. to make them bold red: `pagination_color='\x1b[1;31m'`; can also use codes from blessed like `pagination=Terminal().yellow`) ## Community Projects diff --git a/example/scroll.py b/example/scroll.py index 73efca1..987283c 100644 --- a/example/scroll.py +++ b/example/scroll.py @@ -1,4 +1,5 @@ from pick import pick +from blessed.terminal import Terminal title = "Select:" options = [f"foo.bar{x}.baz" * 100 for x in range(1, 71)] diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 61ad6ac..a79f05b 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -56,6 +56,8 @@ class Picker: term: Optional[blessed.Terminal] = None idxes_in_scope: List[int] = field(init=False, default_factory=list) filter: str = field(init=False, default_factory=str) + disabled_color: str = ("",) + pagination_color: str = "" # screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: @@ -303,12 +305,14 @@ def _display_screen(self) -> None: print(self.title) if chunk_to_render > 0: - print(" ( scroll up to reveal previous entries )") + print( + f"{self.pagination_color} ( scroll up to reveal previous entries ){self.term.normal}" + ) for val in chunked_choices[chunk_to_render]: selectable = "" if isinstance(val[1], Option) and not val[1].enabled: - selectable = self.term.gray35 + selectable = self.disabled_color is_selected = "" if self.multiselect: @@ -328,7 +332,9 @@ def _display_screen(self) -> None: ) if chunk_to_render < len(chunked_choices) - 1: - print(f" ( scroll down to reveal additional entries )") + print( + f"{self.pagination_color} ( scroll down to reveal additional entries ){self.term.normal}" + ) def pick( @@ -341,8 +347,11 @@ def pick( position: Position = Position(0, 0), clear_screen: bool = True, quit_keys: Optional[Iterable[int]] = None, + disabled_color: str = blessed.Terminal().gray35, + pagination_color: str = "", ) -> PICK_RETURN_T: term = blessed.Terminal() + term.yellow picked = None with ( term.fullscreen(), @@ -362,6 +371,8 @@ def pick( position=position, quit_keys=quit_keys, term=term, + disabled_color=disabled_color, + pagination_color=pagination_color, ).start() return picked From 3a46aa2898d114b991ec2239c21bb983311feecd Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:33:58 -0400 Subject: [PATCH 12/21] docs: update README --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++- src/pick/__init__.py | 23 +++++++++++++++----- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cc0028e..2cbc7d2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,57 @@ interactive selection list in the terminal. ## Options +### `Option` + +Provides a an alternative to a simple string value to select from. + +- `label`: The string of the option that is displayed in the menu +- `value`: Optional different value to assign to the option to leverage + once selected. +- `description`: Optional text that is rendered alongside the label in the + menu. +- `enabled`: Whether the option is selectable. By default, is `True`. +- `color`: The color the option is printed with to the menu. By default + is colorless. Accepts a string like ANSI code. e.g. to make it bold red: + `color='\x1b[1;31m'`; can also use codes from blessed like + `color=Terminal().green`) + +Is leveraged by passing in to `pick()`: + +```python +pick( + options=[ + Option( + "Option 1", + "option_1_value", + "This is option 1 and is not selectable", + enabled=False, + ), + "option 2", + "option 3", + Option( + "Option 4", + "option 4", + "This is option 4 and selectable and green", + enabled=True, + color=blessed.Terminal().green, + ), + "option 5", + Option( + "Option 6", + "option 6", + "this is option 6 and colored but unselectable", + enabled=False, + color=blessed.Terminal().pink, + ), + "option 5", + ], + ... +) +``` + +### `pick` + - `options`: a list of options to choose from - `title`: (optional) a title above options list - `indicator`: (optional) custom the selection indicator, defaults to `*` @@ -56,7 +107,6 @@ interactive selection list in the terminal. multiple items by hitting SPACE - `min_selection_count`: (optional) for multi select feature to dictate a minimum of selected items before continuing -- `screen`: (optional), if you are using `pick` within an existing curses application set this to your existing `screen` object. It is assumed this has initialised in the standard way (e.g. via `curses.wrapper()`, or `curses.noecho(); curses.cbreak(); screen.kepad(True)`) - `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. diff --git a/src/pick/__init__.py b/src/pick/__init__.py index a79f05b..f3afcd7 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -26,11 +26,10 @@ class Option: value: Any = None description: Optional[str] = None enabled: bool = True + color: str = "" def __str__(self) -> str: - return ( - f"{self.label}{' (' + self.description + ')' if self.description else ''}" - ) + return f"{self.color}{self.label}{' (' + self.description + ')' if self.description else ''}" OPTION_T = Union[Option, str] @@ -154,7 +153,11 @@ def get_option_lines(self) -> List[str]: ) prefix = f"{prefix} {symbol}" - option_as_str = option.label if isinstance(option, Option) else option + option_as_str = ( + f"{option.color}{option.label}{self.term.normal}" + if isinstance(option, Option) + else option + ) lines.append(f"{prefix} {option_as_str}") return lines @@ -394,9 +397,19 @@ def pick( Option( "Option 4", "option 4", - "this is option 4 and selectable", + "this is option 4 and selectable and green", enabled=True, + color=blessed.Terminal().green, + ), + "option 5", + Option( + "Option 6", + "option 6", + "this is option 6 and colored but unselectable", + enabled=False, + color=blessed.Terminal().pink, ), + "option 5", ], "(Up/down/tab to move; space to select/de-select; Enter to continue)", indicator="=>", From 0ed5ca4ade3ee5fc4788c98507698b4baefb877d Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:17:56 -0400 Subject: [PATCH 13/21] chore: remove unused vars --- src/pick/__init__.py | 53 ++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index f3afcd7..e284165 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -191,8 +191,13 @@ def start(self) -> PICK_RETURN_T: ) errmsg = "" - with self.term.fullscreen(), self.term.cbreak(), self.term.hidden_cursor(): - print(self.term.clear()) + with ( + self.term.fullscreen(), + self.term.cbreak(), + self.term.location(self.position.x, self.position.y), + ): + if self.clear_screen: + print(self.term.clear()) self._display_screen() selection_inprogress = True @@ -347,38 +352,28 @@ def pick( default_index: int = 0, multiselect: bool = False, min_selection_count: int = 0, - position: Position = Position(0, 0), + position: Position = Position(1, 0), clear_screen: bool = True, quit_keys: Optional[Iterable[int]] = None, disabled_color: str = blessed.Terminal().gray35, pagination_color: str = "", ) -> PICK_RETURN_T: - term = blessed.Terminal() - term.yellow - picked = None - with ( - term.fullscreen(), - term.cbreak(), - term.location(position.x, position.y), - ): - picked = Picker( - options=options, - title=title, - indicator=indicator, - default_index=default_index, - multiselect=multiselect, - min_selection_count=min_selection_count, - selected_indexes=[], - index=0, - clear_screen=clear_screen, - position=position, - quit_keys=quit_keys, - term=term, - disabled_color=disabled_color, - pagination_color=pagination_color, - ).start() - - return picked + return Picker( + options=options, + title=title, + indicator=indicator, + default_index=default_index, + multiselect=multiselect, + min_selection_count=min_selection_count, + selected_indexes=[], + index=0, + clear_screen=clear_screen, + position=position, + quit_keys=quit_keys, + term=blessed.Terminal(), + disabled_color=disabled_color, + pagination_color=pagination_color, + ).start() if __name__ == "__main__": From 4aefb6dfb0dcd6932bb8b8447e8e0ea34ddc4140 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:06:50 -0400 Subject: [PATCH 14/21] fix: filtering for colored strings --- src/pick/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index e284165..486df91 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -254,11 +254,16 @@ def _display_screen(self) -> None: options_with_idx = [c for c in enumerate(self.options)] if self.filter: - options_with_idx = [ - choice - for choice in options_with_idx - if str(choice[1]).startswith(self.filter) - ] + new = [] + for choice in options_with_idx: + opt = choice[1] + if isinstance(opt, Option): + if opt.label.startswith(self.filter) and opt.enabled: + new.append(choice) + else: + if opt.startswith(self.filter): + new.append(choice) + options_with_idx = new if not options_with_idx: self.idxes_in_scope = [] From a71613e5e36101de073f8fbdfb363db359338143 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:16:12 -0400 Subject: [PATCH 15/21] fix: filtering for colored strings (2) --- src/pick/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 486df91..2eff6d8 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -13,6 +13,7 @@ ) import blessed from math import ceil +from re import compile as re_compile __all__ = ["pick", "Picker", "Option"] @@ -20,6 +21,11 @@ SYMBOL_CIRCLE_EMPTY = "( )" +def escape_ansi(line: str) -> str: + ansi_escape = re_compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") + return ansi_escape.sub("", line) + + @dataclass class Option: label: str @@ -258,10 +264,10 @@ def _display_screen(self) -> None: for choice in options_with_idx: opt = choice[1] if isinstance(opt, Option): - if opt.label.startswith(self.filter) and opt.enabled: + if escape_ansi(opt.label).startswith(self.filter) and opt.enabled: new.append(choice) else: - if opt.startswith(self.filter): + if escape_ansi(opt).startswith(self.filter): new.append(choice) options_with_idx = new From 65466fffeff32a8068e6e98f16ba16cad6361b8f Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 16:35:25 -0400 Subject: [PATCH 16/21] fix: coloring for disabled but colored options --- src/pick/__init__.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 2eff6d8..d99aaa0 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -329,9 +329,10 @@ def _display_screen(self) -> None: ) for val in chunked_choices[chunk_to_render]: - selectable = "" + unselectable = "" + val_label = str(val[1]) if isinstance(val[1], Option) and not val[1].enabled: - selectable = self.disabled_color + unselectable = self.disabled_color is_selected = "" if self.multiselect: @@ -340,15 +341,20 @@ def _display_screen(self) -> None: if val[0] not in self.selected_indexes else f"{SYMBOL_CIRCLE_FILLED} " ) + if unselectable: + # need to scrub all color + label = ( + unselectable + + escape_ansi(is_selected + val_label) + + self.term.normal + ) + else: + label = f"{is_selected}{val_label}{self.term.normal}" if val[0] == self.index: - print( - f"{self.indicator} {selectable}{is_selected}{val[1]}{self.term.normal}" - ) + print(f"{self.indicator} {label}") else: - print( - f"{' ' * (len(self.indicator) + 1)}{selectable}{is_selected}{val[1]}{self.term.normal}" - ) + print(f"{' ' * (len(self.indicator) + 1)}{label}") if chunk_to_render < len(chunked_choices) - 1: print( From 9ee96b51b13f1dd8834064c9ea8ad42d7342437c Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Wed, 30 Apr 2025 17:10:27 -0400 Subject: [PATCH 17/21] fix: don't let user enter when too filtered --- src/pick/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index d99aaa0..15a255f 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -198,6 +198,7 @@ def start(self) -> PICK_RETURN_T: errmsg = "" with ( + self.term.hidden_cursor(), self.term.fullscreen(), self.term.cbreak(), self.term.location(self.position.x, self.position.y), @@ -221,7 +222,11 @@ def start(self) -> PICK_RETURN_T: ): self.mark_index() elif key.name == "KEY_ENTER": - if ( + if len(self.idxes_in_scope) == 0: + # don't let the user enter when + # filtered too constrictively + continue + elif ( self.multiselect and len(self.selected_indexes) < self.min_selection_count ): From 0986d4138c4e2d0ed48159ab9264076d4d2e0512 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Thu, 1 May 2025 08:51:48 -0400 Subject: [PATCH 18/21] fix: mypy --- src/pick/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index 15a255f..790a7e5 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -61,7 +61,7 @@ class Picker: term: Optional[blessed.Terminal] = None idxes_in_scope: List[int] = field(init=False, default_factory=list) filter: str = field(init=False, default_factory=str) - disabled_color: str = ("",) + disabled_color: str = "" pagination_color: str = "" # screen: Optional["curses._CursesWindow"] = None @@ -144,6 +144,7 @@ def get_title_lines(self) -> List[str]: return [] def get_option_lines(self) -> List[str]: + self.term = cast(blessed.Terminal, self.term) lines: List[str] = [] for index, option in enumerate(self.options): if index == self.index: @@ -285,8 +286,8 @@ def _display_screen(self) -> None: # Chunk logic stuff is required to do scrolling when too many # vertical items - chunked_choices = [] - current_chunk = [] + chunked_choices: list[List[Tuple[int, OPTION_T]]] = [] + current_chunk: List[Tuple[int, OPTION_T]] = [] so_far = 0 chunk_to_render = 0 page = 0 @@ -297,7 +298,8 @@ def _display_screen(self) -> None: linesize = len(pairing[1]) # height of title, plus two pagination lines, plus some extra room: - pad_height = ceil(len(self.title) / self.term.width) + 2 + 10 + title_size = 0 if not self.title else len(self.title) + pad_height = ceil(title_size / self.term.width) + 2 + 10 lines_used = ceil((linesize + pad_height) / self.term.width) so_far += lines_used if so_far > (self.term.height - pad_height): From 9d2765066193a75954afdc410ecb317f4ef87ae3 Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Fri, 2 May 2025 07:39:11 -0400 Subject: [PATCH 19/21] fix: with syntax for PICK_RETURN_T: def start(self) -> PICK_RETURN_T: self.term = cast(blessed.Terminal, self.term) - _quit_keys: list[str] = ( + _quit_keys: List[str] = ( [] if self.quit_keys is None else [chr(key_code) for key_code in self.quit_keys] ) errmsg = "" - with ( - self.term.hidden_cursor(), - self.term.fullscreen(), - self.term.cbreak(), - self.term.location(self.position.x, self.position.y), - ): + # Note: can't use parenthesis here bc that is >= 3.10 + with self.term.hidden_cursor(), \ + self.term.fullscreen(), \ + self.term.cbreak(), \ + self.term.location(self.position.x, self.position.y): if self.clear_screen: print(self.term.clear()) self._display_screen() @@ -286,7 +284,7 @@ def _display_screen(self) -> None: # Chunk logic stuff is required to do scrolling when too many # vertical items - chunked_choices: list[List[Tuple[int, OPTION_T]]] = [] + chunked_choices: List[List[Tuple[int, OPTION_T]]] = [] current_chunk: List[Tuple[int, OPTION_T]] = [] so_far = 0 chunk_to_render = 0 From 39881a750ed785c9bcc59cb464b437d7d9cc005d Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Fri, 2 May 2025 15:23:51 -0400 Subject: [PATCH 20/21] feat: ability to leverage custom keys --- README.md | 61 +++++++++++++++++++-- example/basic.py | 10 ++-- example/multiselect.py | 53 ++++++++++++++++-- example/scroll.py | 4 +- src/pick/__init__.py | 120 ++++++++++++++++++++++++++--------------- 5 files changed, 189 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 2cbc7d2..6db37f5 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,62 @@ pick( multiple items by hitting SPACE - `min_selection_count`: (optional) for multi select feature to dictate a minimum of selected items before continuing -- `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. -- `disabled_color`: (optional) if you want to change the color that disabled options are; by default, is grey. Accepts a string like ANSI code. e.g. to make them bold red: `disabled_color='\x1b[1;31m'`; can also use codes from blessed like `disabled_color=Terminal().yellow`; to make it not-colored, `disabled_color=""`) -- `pagination_color`: (optional) if you want to change the color that the pagination messages (shown when there is multiple pages of content to scroll through); by default, is uncolored. Accepts a string like ANSI code. e.g. to make them bold red: `pagination_color='\x1b[1;31m'`; can also use codes from blessed like `pagination=Terminal().yellow`) +- `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; default `ESC` key), if you want to quit + early, you can pass a key codes. If the corresponding key are pressed, + it will quit the menu. +- `up_keys`: (optional; default: up arrow), keys to move the menu + selection up +- `down_keys`: (optional; default: down arrow or tab), keys to move + the menu selection down +- `select_keys`: (optional; default: space key), When in multiselect mode, + keys to add/remove the current selection to/from the list of selected + items +- `enter_keys`: (optional; default: enter key), Key to confirm choices + and close menu +- `disabled_color`: (optional) if you want to change the color that + disabled options are; by default, is grey. Accepts a string like ANSI + code. e.g. to make them bold red: `disabled_color='\x1b[1;31m'`; can + also use codes from blessed like `disabled_color=Terminal().yellow`; + to make it not-colored, `disabled_color=""`) +- `pagination_color`: (optional) if you want to change the color that + the pagination messages (shown when there is multiple pages of content + to scroll through); by default, is uncolored. Accepts a string like + ANSI code. e.g. to make them bold red: `pagination_color='\x1b[1;31m'`; + can also use codes from blessed like `pagination=Terminal().yellow`) + +#### Specifying keys + +* Refer to list of names from the [blessed docs](https://blessed.readthedocs.io/en/latest/keyboard.html#id1) + +If the key you wish to use is a single non-system key (i.e alphanumeric), +then you can use `ord('')`; however, in order to specify system keys, +you must specify them to the `*_keys` parameter like so: + +```python +from blessed.keyboard import get_curses_keycodes # type: ignore + +# Would allow the user to select current selection with +# BOTH space (default) and the right arrow: +pick(..., select_keys = [get_curses_keycodes()["KEY_RIGHT"], ...) +``` + +Note that options passed are _additive_ to the defaults. If you wish +to override the defaults entirely, you must also set the constant values +to nothing before calling `pick`: + +```python +import pick + +# overwrite UP_KEYS, DOWN_KEYS, SELECT_KEYS, ENTER_KEYS, QUIT_KEYS +pick.UP_KEYS = [] + +# would result in "u" being the only way to move the cursor up +pick(..., up_keys = [ord('U')], ...) +``` + ## Community Projects diff --git a/example/basic.py b/example/basic.py index fad055d..a913615 100644 --- a/example/basic.py +++ b/example/basic.py @@ -1,8 +1,12 @@ from pick import pick +from typing import List +from blessed.keyboard import get_curses_keycodes # type: ignore -KEY_CTRL_C = 3 -KEY_ESCAPE = 27 -QUIT_KEYS = (KEY_CTRL_C, KEY_ESCAPE, ord("q")) +keystrokes = get_curses_keycodes() + +# https://blessed.readthedocs.io/en/latest/keyboard.html +# KEY_EXIT is the escape key +QUIT_KEYS: List[int] = [ord("q"), keystrokes["KEY_EXIT"], keystrokes["KEY_F1"]] title = "Please choose your favorite programming language: " options = ["Java", "JavaScript", "Python", "PHP", "C++", "Erlang", "Haskell"] diff --git a/example/multiselect.py b/example/multiselect.py index 0b5fb4b..3dbacee 100644 --- a/example/multiselect.py +++ b/example/multiselect.py @@ -1,6 +1,49 @@ -from pick import pick +#!/usr/bin/env python -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) -print(selected) +from typing import List +from blessed import Terminal +from blessed.keyboard import get_curses_keycodes # type: ignore +import pick + +pick.SELECT_KEYS = [] + +options: List[pick.OPTION_T] = [ + pick.Option( + "Option 1", + "value_field", + "Description field is printed, too (this option is disabled)", + enabled=False, + ), + "option 2", + "option 3", + pick.Option("Option 4", "value", "This option is green", color=Terminal().green), + "option 5", + pick.Option( + "Option 6", + "value", + "This is a colored but disabled option", + enabled=False, + color=Terminal().pink, + ), + "option 7", +] + +prompt = ( + "(Up/down/tab to move; space to select/de-select; Enter to continue; " + + "To filter options, simply begin typing)" +) + +# Note that the disabled choices are dark grey by default, but can be +# a custom color via the disabled_color= option: +choice = pick.pick( + options, + prompt, + indicator="=>", + multiselect=True, + min_selection_count=2, + quit_keys=[ord("q")], + select_keys=[get_curses_keycodes()["KEY_RIGHT"]], + disabled_color=Terminal().gray35, +) + +print(f"You chose: {choice}") diff --git a/example/scroll.py b/example/scroll.py index 987283c..f95afdf 100644 --- a/example/scroll.py +++ b/example/scroll.py @@ -1,7 +1,7 @@ from pick import pick from blessed.terminal import Terminal -title = "Select:" +title = "Select (begin typing to filter options):" options = [f"foo.bar{x}.baz" * 100 for x in range(1, 71)] -option, index = pick(options, title) +option, index = pick(options, title, pagination_color=Terminal().green) print(option, index) diff --git a/src/pick/__init__.py b/src/pick/__init__.py index b94cea6..5eeb340 100755 --- a/src/pick/__init__.py +++ b/src/pick/__init__.py @@ -1,9 +1,9 @@ -from collections import namedtuple from dataclasses import dataclass, field from typing import ( Any, Iterable, List, + NamedTuple, Optional, Sequence, Tuple, @@ -13,12 +13,24 @@ import blessed from math import ceil from re import compile as re_compile +from blessed.keyboard import get_curses_keycodes, get_keyboard_codes # type: ignore __all__ = ["pick", "Picker", "Option"] + SYMBOL_CIRCLE_FILLED = "(x)" SYMBOL_CIRCLE_EMPTY = "( )" +_keys = get_curses_keycodes() +UP_KEYS: List[int] = [_keys["KEY_UP"]] +DOWN_KEYS: List[int] = [ + _keys["KEY_DOWN"], + next(code for code, key in get_keyboard_codes().items() if key == "KEY_TAB"), +] +SELECT_KEYS: List[int] = [ord(" ")] +ENTER_KEYS: List[int] = [_keys["KEY_ENTER"]] +QUIT_KEYS: List[int] = [_keys["KEY_EXIT"]] + def escape_ansi(line: str) -> str: ansi_escape = re_compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") @@ -41,7 +53,10 @@ def __str__(self) -> str: _pick_type = Tuple[Optional[OPTION_T], int] PICK_RETURN_T = Union[List[_pick_type], _pick_type] -Position = namedtuple("Position", ["y", "x"]) + +class Position(NamedTuple): + y: int + x: int @dataclass @@ -56,14 +71,17 @@ class Picker: index: int = field(init=True, default=0) position: Position = Position(0, 0) clear_screen: bool = True - quit_keys: Optional[Iterable[int]] = None + quit_keys: List[int] = field(init=True, default_factory=list) term: Optional[blessed.Terminal] = None idxes_in_scope: List[int] = field(init=False, default_factory=list) filter: str = field(init=False, default_factory=str) + up_keys: List[int] = field(init=True, default_factory=list) + down_keys: List[int] = field(init=True, default_factory=list) + enter_keys: List[int] = field(init=True, default_factory=list) + select_keys: List[int] = field(init=True, default_factory=list) disabled_color: str = "" pagination_color: str = "" - # screen: Optional["curses._CursesWindow"] = None def __post_init__(self) -> None: if len(self.options) == 0: raise ValueError("options should not be an empty list") @@ -89,6 +107,7 @@ def __post_init__(self) -> None: option = self.options[self.index] self.idxes_in_scope = list(range(len(self.options))) self.filter = "" + if isinstance(option, Option) and not option.enabled: self.move_down() @@ -190,18 +209,15 @@ def get_selected(self) -> PICK_RETURN_T: def start(self) -> PICK_RETURN_T: self.term = cast(blessed.Terminal, self.term) - _quit_keys: List[str] = ( - [] - if self.quit_keys is None - else [chr(key_code) for key_code in self.quit_keys] - ) errmsg = "" # Note: can't use parenthesis here bc that is >= 3.10 - with self.term.hidden_cursor(), \ - self.term.fullscreen(), \ - self.term.cbreak(), \ - self.term.location(self.position.x, self.position.y): + with ( + self.term.hidden_cursor(), + self.term.fullscreen(), + self.term.cbreak(), + self.term.location(self.position.x, self.position.y), + ): if self.clear_screen: print(self.term.clear()) self._display_screen() @@ -209,37 +225,40 @@ def start(self) -> PICK_RETURN_T: selection_inprogress = True while selection_inprogress: key = self.term.inkey() - if key.is_sequence or key == " ": - if key.name in {"KEY_TAB", "KEY_DOWN"}: - self.move_down() - elif key.name == "KEY_UP": - self.move_up() + key_code = ord(key) if not key.is_sequence else key.code + if key_code in self.quit_keys: + return None, -1 + + if key_code in self.down_keys: + self.move_down() + elif key_code in self.up_keys: + self.move_up() + elif ( + key_code in self.select_keys + and self.multiselect + and len(self.idxes_in_scope) != 0 + ): + self.mark_index() + elif key_code in self.enter_keys: + if len(self.idxes_in_scope) == 0: + # don't let the user enter when + # filtered too constrictively + continue elif ( - key == " " - and self.multiselect - and len(self.idxes_in_scope) != 0 + self.multiselect + and len(self.selected_indexes) < self.min_selection_count ): - self.mark_index() - elif key.name == "KEY_ENTER": - if len(self.idxes_in_scope) == 0: - # don't let the user enter when - # filtered too constrictively - continue - elif ( - self.multiselect - and len(self.selected_indexes) < self.min_selection_count - ): - errmsg = f"{self.term.red}Must select at least {self.min_selection_count} entry(s)!{self.term.normal}" - else: - return self.get_selected() - elif key.name == "KEY_BACKSPACE": - self.filter = self.filter[:-1] if self.filter else "" - else: - if key.lower() in _quit_keys: - return None, -1 + errmsg = ( + f"{self.term.red}Must select at least {self.min_selection_count} " + + f"entry(s)!{self.term.normal}" + ) else: - # Keystroke gets appended to the current filter - self.filter += key + return self.get_selected() + elif key.is_sequence and key.name == "KEY_BACKSPACE": + self.filter = self.filter[:-1] if self.filter else "" + else: + # Is not a special key, so add it to the current filter + self.filter += key print(self.term.clear()) @@ -264,7 +283,7 @@ def _display_screen(self) -> None: options_with_idx = [c for c in enumerate(self.options)] if self.filter: - new = [] + new: list[Tuple[int, OPTION_T]] = [] for choice in options_with_idx: opt = choice[1] if isinstance(opt, Option): @@ -376,10 +395,20 @@ def pick( min_selection_count: int = 0, position: Position = Position(1, 0), clear_screen: bool = True, - quit_keys: Optional[Iterable[int]] = None, + up_keys: Optional[List[int]] = None, + down_keys: Optional[List[int]] = None, + select_keys: Optional[List[int]] = None, + enter_keys: Optional[List[int]] = None, + quit_keys: Optional[List[int]] = None, disabled_color: str = blessed.Terminal().gray35, pagination_color: str = "", ) -> PICK_RETURN_T: + up_keys = UP_KEYS if up_keys is None else UP_KEYS + up_keys + down_keys = DOWN_KEYS if down_keys is None else DOWN_KEYS + down_keys + select_keys = SELECT_KEYS if select_keys is None else SELECT_KEYS + select_keys + enter_keys = ENTER_KEYS if enter_keys is None else ENTER_KEYS + enter_keys + quit_keys = QUIT_KEYS if quit_keys is None else QUIT_KEYS + quit_keys + return Picker( options=options, title=title, @@ -391,6 +420,10 @@ def pick( index=0, clear_screen=clear_screen, position=position, + up_keys=up_keys, + down_keys=down_keys, + select_keys=select_keys, + enter_keys=enter_keys, quit_keys=quit_keys, term=blessed.Terminal(), disabled_color=disabled_color, @@ -431,7 +464,6 @@ def pick( "(Up/down/tab to move; space to select/de-select; Enter to continue)", indicator="=>", multiselect=True, - quit_keys=[ord("q")], clear_screen=False, min_selection_count=2, ), From a3320e7f23f36f877cdf68d5bb405dfa56ff691f Mon Sep 17 00:00:00 2001 From: George Pickering <9803299+bigpick@users.noreply.github.com> Date: Fri, 2 May 2025 15:24:44 -0400 Subject: [PATCH 21/21] docs: fix upper U --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6db37f5..8e7467a 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ import pick # overwrite UP_KEYS, DOWN_KEYS, SELECT_KEYS, ENTER_KEYS, QUIT_KEYS pick.UP_KEYS = [] -# would result in "u" being the only way to move the cursor up +# would result in "U" being the only way to move the cursor up pick(..., up_keys = [ord('U')], ...) ```