Compare commits

...

27 Commits

Author SHA1 Message Date
Sebastian Lohff fdfd0d0101 Add ctrl+n / ctrl+p as shortcuts for down and up
ctrl+n is used for "next item" aka down, ctrl+p as "previous item" aka
up.

Fixes #1
2023-02-16 18:04:32 +01:00
Sebastian Lohff 26412d400f Fix off-by-one in initial pos for menus with no header
Menus without a header had their initial position set to the second
element by default. This was due to most menus having a header and us
calling next_item() unconditionally. Now we first go to the first
focusable item and then call next_item() as many times as needed to get
to the specified initial_pos.
2023-02-16 10:53:02 +01:00
Sebastian Lohff 33aa41ac6e Return with "no success" when no options are given
If the user gave us no options then we used to just raise a ValueError
(due to initial_pos being out of range). In most - if not all - cases
it would be more favorable to act the same way as if the selection has
been aborted, so that's what we're doing now.
2023-02-16 10:52:14 +01:00
Sebastian Lohff 1a309d28c0 Release v0.2.0 2020-09-15 23:20:57 +02:00
Sebastian Lohff 775c50ed6f Add support for cursorless menus
When a highlighter is used instead of a cursor it looks much better when
all the options have the same length. With right_pad_options all options
are right-padded with spaces so they have the same length. Also, an
example for a cursorless menu is added.
2020-09-06 21:08:06 +02:00
Sebastian Lohff 7a09d45ab8 Allow styling special CliMultiMenu states
Now a selected and a selected and highlighted option can be styled.
2020-08-28 01:58:39 +02:00
Sebastian Lohff 19ad4f3e8b Allow per-item styling for text and options
When adding text or an option a style can now be passed along. An option
allows an additional highlighted_style to be passed.
2020-08-28 00:46:32 +02:00
Sebastian Lohff 030492b353 Rename CliMenuTheme class parameters
The _style is redundant, as the whole theme is a collection of styles.
Headers are not really regarded as headers, more as text. Highlight
makes more sense as highlighted.

This is an API breaking change.
2020-08-28 00:43:03 +02:00
Sebastian Lohff 22307bc4ae Always use text as item if no item was specified
When an option is passed without an item attached we now always use the
label as item. This was the behaviour for __init__() and is now also the
behaviour of add_option().
2020-08-28 00:26:32 +02:00
Sebastian Lohff 85c3c5c947 Make option prefix/suffix configurable
This can be used to add some text surrounding options or to remove the
space between cursor and option item.
2020-08-28 00:18:53 +02:00
Sebastian Lohff 68e6ecbe4f Make CliMenuHeader and CliMenuOption private
These classes don't need to be used by the user, only via the API, so we
can mark them private.
2020-08-26 23:46:35 +02:00
Sebastian Lohff 24dab5f69b Specify minimum selected item count for CliMutliMenu
min_selection_count=n can be specify to indicate that at least n
items need to be selected before the selection is accepted.
Feature inspired by python pick.
2020-08-21 00:09:12 +02:00
Sebastian Lohff 56b76706f6 Group selection icons in CliMultiMenu API
The select/unselect icons of a CliMultiMenu will in nearly all cases be
changed together, therefore it makes sense to let the API be less
verbose for this case.

This is an API breaking change for CliMultiMenu
2020-08-21 00:09:12 +02:00
Sebastian Lohff 12552d8e5b Use right arrow to select/deselect in CliMultiMenu
For a menu where we only select one option right arrow is an okay-choice
for accept, but with multiple option selection toggling feels more
natural.
2020-08-20 01:53:48 +02:00
Sebastian Lohff d01024d1be Add initial_pos to choose initially selected item 2020-08-20 01:39:35 +02:00
Sebastian Lohff 3e3ea53f5e Remove dead code 2020-08-20 01:28:05 +02:00
Sebastian Lohff c363c187bd Remove unused methods and unused arguments 2020-08-20 00:15:41 +02:00
Sebastian Lohff 94bb03dc07 Don't ignore cursor/style/icon if it is not None
To allow the user to set '' as a cursor we need to distinguish between
'' and None, same for select icons and styles.
2020-08-20 00:14:08 +02:00
Sebastian Lohff 4ba22a3a33 Make setup.cfg release ready, move to GitHub 2020-08-16 19:58:55 +02:00
Sebastian Lohff 34a3279e2f Improve README.md a tiny bit 2020-08-16 15:13:22 +02:00
Sebastian Lohff f20b5eac89 Add more examples 2020-08-16 14:24:47 +02:00
Sebastian Lohff 9b833db364 Check type of options passed to __init__ 2020-08-05 03:09:24 +02:00
Sebastian Lohff c74e3d68d6 Implement multiple selections with CliMultiMenu
CliMultiMenu works much in the same way as CliMenu but it allows for
selecting multiple items.
2020-08-05 02:49:05 +02:00
Sebastian Lohff 46a863b3cf Remove broken space keyboard binding for accept
Originally space was thought out to also accept the selection, but only
a broken binding was added. As we want to use space for selections in
the future we now remove it entirely.
2020-08-05 02:34:36 +02:00
Sebastian Lohff a28c76e001 Allow a default theme and cursor to be set 2020-08-05 02:32:33 +02:00
Sebastian Lohff 45d3395642 Rename add_header() to add_text()
add_header() does not necessarily add a header, it might be just text.
2020-07-22 02:08:53 +02:00
Sebastian Lohff a17f7693e9 Renamed CLI_* cursors to ASCII_* cursors 2020-07-22 02:07:45 +02:00
7 changed files with 337 additions and 65 deletions

View File

@ -1,5 +1,9 @@
# CLI-Intermission
Tool to easily ask your user questions on the commandline. Interactive!
...by abusing python prompt-toolkit's `Buffer` class.
# Clintermission - Python CLI Intermission Library
`clintermission` is a small Python library designed to quickly get a selection
of a single or multiple items from a user. The menu shown is explicitly not a
fullscreen menu so the user can use information in their console history to
make a selection.
Highly inspired by [go promptui](https://github.com/manifoldco/promptui)
The menu is implemented by using `prompt-toolkit`'s `Buffer` class.
Inspired by [go promptui](https://github.com/manifoldco/promptui).

View File

@ -1,11 +1,13 @@
# Written by Sebastian Lohff <seba@someserver.de>
# Licensed under Apache License 2.0
from clintermission.climenu import CliMenu, CliMenuStyle, CliMenuCursor, CliMenuTheme, cli_select_item
from clintermission.climenu import CliMenu, CliMultiMenu, CliMenuStyle, CliSelectionStyle, CliMenuCursor, CliMenuTheme, cli_select_item
__all__ = [
CliMenu,
CliMultiMenu,
CliMenuStyle,
CliSelectionStyle,
CliMenuCursor,
CliMenuTheme,
cli_select_item,

View File

@ -12,20 +12,23 @@ from prompt_toolkit import search
from prompt_toolkit.widgets import SearchToolbar
class CliMenuHeader:
class _CliMenuHeader:
"""Hold a menu header"""
def __init__(self, text, indent=False):
def __init__(self, text, indent=False, style=None):
self.text = text
self.indent = indent
self.style = style
self.focusable = False
class CliMenuOption:
class _CliMenuOption:
"""Hold a menu option"""
def __init__(self, text, num, item=None):
def __init__(self, text, num, item=None, style=None, highlighted_style=None):
self.text = text
self.num = num
self.item = item
self.style = style
self.highlighted_style = highlighted_style
self.focusable = True
@ -33,9 +36,9 @@ class CliMenuCursor:
"""Collection of cursors pointing at the active menu item"""
BULLET = ''
TRIANGLE = ''
CLI_STAR = '*'
CLI_ARROW = '-->'
CLI_CAT = '=^.^='
ASCII_STAR = '*'
ASCII_ARROW = '-->'
ASCII_CAT = '=^.^='
CAT = '😸'
ARROW = ''
@ -45,71 +48,95 @@ class CliMenuStyle:
Allows to select header, option and selected option color
"""
def __init__(self, option_style='', highlight_style='', header_style=''):
self.option_style = option_style
self.highlight_style = highlight_style
self.header_style = header_style
def __init__(self, option='', highlighted='', text='', selected=None, selected_highlighted=None):
self.option = option
self.highlighted = highlighted
self.text = text
self.selected = selected
self.selected_highlighted = selected_highlighted
# self.option_color = '#aa0000'
# #self.highlight_color = 'fg:ansiblue bg:ansired bold'
# self.highlight_color = 'bold'
# self.cursor = ' --> '
# self.cursor = ' ● '
# self.no_cursor = ' '
# self.header_color = '#aa22cc'
# self.option_indent = 4
# self.header_indent = 4
class CliSelectionStyle:
SQUARE_BRACKETS = ('[x]', '[ ]')
ROUND_BRACKETS = ('(x)', '( )')
CHECKMARK = ('', '')
THUMBS = ('👍', '👎')
SMILEY = ('🙂', '🙁')
SMILEY_EXTREME = ('😁', '😨')
class CliMenuTheme:
BASIC = CliMenuStyle()
BASIC_BOLD = CliMenuStyle(header_style='bold', highlight_style='bold')
BASIC_BOLD = CliMenuStyle(text='bold', highlighted='bold')
RED = CliMenuStyle('#aa0000', '#ee0000', '#aa0000')
CYAN = CliMenuStyle('cyan', 'lightcyan', 'cyan')
BLUE = CliMenuStyle('ansiblue', 'ansired', 'ansiblue')
ANSI_CYAN = CliMenuStyle('ansicyan', 'ansibrightcyan', 'ansicyan')
BOLD_HIGHLIGHT = CliMenuStyle(header_style='bold', highlight_style='bold fg:black bg:white')
BOLD_HIGHLIGHT = CliMenuStyle(text='bold', highlighted='bold fg:black bg:white')
class _EmptyParameter:
pass
class CliMenu:
default_style = CliMenuTheme.BASIC
default_cursor = CliMenuCursor.TRIANGLE
@classmethod
def set_default_style(cls, style):
cls.default_style = style
@classmethod
def set_default_cursor(cls, cursor):
cls.default_cursor = cursor
def __init__(self, options=None, header=None, cursor=None, style=None,
indent=2, dedent_selection=False):
indent=2, dedent_selection=False, initial_pos=0,
option_prefix=' ', option_suffix='', right_pad_options=False):
self._items = []
self._item_num = 0
self._ran = False
self._success = None
self._pos = 0
self._initial_pos = initial_pos
self._option_prefix = option_prefix
self._option_suffix = option_suffix
self._option_indent = indent
self._header_indent = indent
self._dedent_selection = dedent_selection
self._right_pad_options = right_pad_options
self._cursor = cursor
if not self._cursor:
self._cursor = self.default_cursor
self._style = style
if not self._style:
self._style = self.default_style
self._cursor = cursor if cursor is not None else self.default_cursor
self._style = style if style is not None else self.default_style
if header:
self.add_header(header, indent=False)
self.add_text(header, indent=False)
if options:
for option in options:
if isinstance(option, tuple) and len(option) == 2:
if isinstance(option, tuple):
self.add_option(*option)
elif isinstance(option, dict):
self.add_option(**option)
elif isinstance(option, str):
self.add_option(option)
else:
self.add_option(option, option)
raise ValueError("Option needs to be either tuple, dict or string, found '{}' of type {}"
.format(option, type(option)))
def add_header(self, title, indent=True):
def add_header(self, *args, **kwargs):
return self.add_text(*args, **kwargs)
def add_text(self, title, indent=True, style=None):
for text in title.split('\n'):
self._items.append(CliMenuHeader(text, indent=indent))
self._items.append(_CliMenuHeader(text, indent=indent, style=style))
def add_option(self, text, item=None):
self._items.append(CliMenuOption(text, self._item_num, item=item))
def add_option(self, text, item=_EmptyParameter, style=None, highlighted_style=None):
if item == _EmptyParameter:
item = text
self._items.append(_CliMenuOption(text, self._item_num, item=item,
style=style, highlighted_style=highlighted_style))
self._item_num += 1
@property
@ -120,7 +147,7 @@ class CliMenu:
return self._success
def get_options(self):
return [_item for _item in self._items if isinstance(_item, CliMenuOption)]
return [_item for _item in self._items if isinstance(_item, _CliMenuOption)]
@property
def num_options(self):
@ -129,7 +156,6 @@ class CliMenu:
def get_selection(self):
if self.success:
item = self._items[self._pos]
return (item.num, item.item)
else:
return (None, None)
@ -140,41 +166,46 @@ class CliMenu:
def get_selection_item(self):
return self.get_selection()[1]
def cursor(self):
return '{} '.format(self._cursor)
def _transform_prefix(self, item, lineno, prefix):
return prefix
@property
def no_cursor(self):
# cursor with spaces minus dedent
return ' ' * (len(self._cursor) + 1 * self._dedent_selection)
def _get_style(self, item, lineno, highlighted):
s = self._style
if item.focusable:
if highlighted:
return item.highlighted_style if item.highlighted_style is not None else s.highlighted
else:
return item.style if item.style is not None else s.option
else:
return item.style if item.style is not None else s.text
def _transform_line(self, ti):
if len(list(ti.fragments)) == 0:
return Transformation(ti.fragments)
style, text = list(ti.fragments)[0]
item = self._items[ti.lineno]
s = self._style
style = self._get_style(item, ti.lineno, ti.lineno == self._pos)
# cursor
indent = ''
prefix = ''
suffix = ''
if item.focusable:
indent += ' ' * self._option_indent
suffix = self._option_suffix
if ti.lineno == self._pos:
prefix += '{} '.format(self._cursor)
style = s.highlight_style
prefix += '{}{}'.format(self._cursor, self._option_prefix)
else:
prefix += ' ' * (len(self._cursor) + 1 + 1 * self._dedent_selection)
style = s.option_style
prefix += ' ' * len(self._cursor) + self._option_prefix + ' ' * self._dedent_selection
else:
if item.indent:
indent += ' ' * (self._header_indent + len(self._cursor) + 1)
style = s.header_style
items = [(s if s else style, t) for s, t in ti.fragments]
prefix = self._transform_prefix(item, ti.lineno, prefix)
return Transformation([('', indent), (style, prefix)] + items)
return Transformation([('', indent), (style, prefix)] + items + [(style, suffix)])
def next_item(self, direction):
if not any(item.focusable for item in self._items):
@ -221,7 +252,32 @@ class CliMenu:
return lines
def _register_extra_kb_cbs(self, kb):
pass
def _preflight(self):
if self._initial_pos < 0 or self._initial_pos >= self._item_num:
raise ValueError("Initial position {} is out of range, needs to be in range of [0, {})"
.format(self._initial_pos, self._item_num))
if self._right_pad_options:
# pad all item labels with spaces to have the same length
max_item_len = max([len(_item.text) for _item in self._items if _item.focusable])
for item in self._items:
if item.focusable:
item.text += " " * (max_item_len - len(item.text))
def _accept(self, event):
self._success = True
event.app.exit()
def _run(self):
if self._item_num == 0:
self._success = False
return
self._preflight()
class MenuColorizer(Processor):
def apply_transformation(_self, ti):
return self._transform_line(ti)
@ -232,21 +288,24 @@ class CliMenu:
@self._kb.add('q', filter=~is_searching)
@self._kb.add('c-c')
def quit(event):
self._success = False
event.app.exit()
@self._kb.add('down', filter=~is_searching)
@self._kb.add('j', filter=~is_searching)
@self._kb.add('c-n', filter=~is_searching)
def down(event):
self.next_item(1)
@self._kb.add('up', filter=~is_searching)
@self._kb.add('k', filter=~is_searching)
@self._kb.add('c-p', filter=~is_searching)
def up(event):
self.next_item(-1)
@self._kb.add('N', filter=~is_searching)
@self._kb.add('n', filter=~is_searching)
def search_inc(event, filter=is_searching):
def search_inc(event):
if not self._bufctrl.search_state.text:
return
@ -258,10 +317,8 @@ class CliMenu:
@self._kb.add('c-m', filter=~is_searching)
@self._kb.add('right', filter=~is_searching)
@self._kb.add('c-space', filter=~is_searching)
def accept(event):
self._success = True
event.app.exit()
self._accept(event)
@self._kb.add('c-m', filter=is_searching)
def accept_search(event):
@ -269,6 +326,8 @@ class CliMenu:
new_line, _ = self._doc.translate_index_to_position(self._buf.cursor_position)
self.sync_cursor_to_line(new_line)
self._register_extra_kb_cbs(self._kb)
self._searchbar = SearchToolbar(ignore_case=True)
text = '\n'.join(map(lambda _x: _x.text, self._items))
@ -284,7 +343,11 @@ class CliMenu:
self._searchbar])
# set initial pos
self.sync_cursor_to_line(0)
while not self._items[self._pos].focusable:
self._pos += 1
for _ in range(self._initial_pos):
self.next_item(1)
app = Application(layout=Layout(split),
key_bindings=self._kb,
@ -295,6 +358,92 @@ class CliMenu:
self._ran = True
class CliMultiMenu(CliMenu):
default_selection_icons = CliSelectionStyle.SQUARE_BRACKETS
@classmethod
def set_default_selector_icons(cls, selection_icons):
cls.default_selection_icons = selection_icons
def __init__(self, *args, selection_icons=None, min_selection_count=0, **kwargs):
self._multi_selected = []
self._min_selection_count = min_selection_count
self._selection_icons = selection_icons if selection_icons is not None else self.default_selection_icons
super().__init__(*args, **kwargs)
def add_option(self, text, item=_EmptyParameter, selected=False,
style=None, highlighted_style=None, selected_style=None, selected_highlighted_style=None):
super().add_option(text, item, style, highlighted_style=highlighted_style)
self._items[-1].selected_style = selected_style
self._items[-1].selected_highlighted_style = selected_highlighted_style
if selected:
self._multi_selected.append(len(self._items) - 1)
def get_selection(self):
if self.success:
return [(self._items[n].num, self._items[n].item) for n in self._multi_selected]
else:
return None
def get_selection_num(self):
if self.success:
return [self._items[n].num for n in self._multi_selected]
else:
return None
def get_selection_item(self):
if self.success:
return [self._items[n].item for n in self._multi_selected]
else:
return None
def _register_extra_kb_cbs(self, kb):
@kb.add('space', filter=~is_searching)
@kb.add('right', filter=~is_searching)
def mark(event):
if self._pos not in self._multi_selected:
self._multi_selected.append(self._pos)
else:
self._multi_selected.remove(self._pos)
def _transform_prefix(self, item, lineno, prefix):
if item.focusable:
if lineno in self._multi_selected:
icon = self._selection_icons[0]
else:
icon = self._selection_icons[1]
return "{}{} ".format(prefix, icon)
else:
return prefix
def _get_style(self, item, lineno, highlighted):
s = self._style
if item.focusable and lineno in self._multi_selected:
if highlighted:
if item.selected_highlighted_style is not None:
return item.selected_highlighted_style
if s.selected_highlighted is not None:
return s.selected_highlighted
else:
if item.selected_style is not None:
return item.selected_style
if s.selected is not None:
return s.selected
# no style specified or no selected state, call parent
return super()._get_style(item, lineno, highlighted)
def _preflight(self):
super()._preflight()
if self._min_selection_count > self._item_num:
raise ValueError("A minimum of {} items was requested for successful selection but only {} exist"
.format(self._min_selection_count, self._item_num))
def _accept(self, event):
if len(self._multi_selected) >= self._min_selection_count:
super()._accept(event)
def cli_select_item(options, header=None, abort_exc=ValueError, abort_text="Selection aborted.", style=None,
return_single=True):
"""Helper function to quickly get a selection with just a few arguments"""

88
examples/basic_usage.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
from clintermission import CliMenu, CliMenuTheme, CliMenuStyle, CliMenuCursor, cli_select_item
# --- basic menu ---
q = ["Foo", "Bar", "Baz"]
m = CliMenu(q, "Time to choose:\n")
print("You selected", m.get_selection())
print()
# basic menu with an item assigned to each option and detentation of selection
q = [("Foo", 23), ("Bar", 34), ("Baz", 42)]
m = CliMenu(q, "Time to choose:\n", cursor="--->", dedent_selection=True)
print("You selected {} index {} item {}".format(m.get_selection(), m.get_selection_num(), m.get_selection_item()))
print()
# --- themes ---
q = ["Foo", "Bar", "Baz"]
m = CliMenu(q, "Time to choose:\n", style=CliMenuTheme.RED)
print("You selected", m.get_selection())
print()
# --- custom themes ---
style = CliMenuStyle(option='blue', highlighted='cyan', text='green')
q = ["Foo", "Bar", "Baz"]
m = CliMenu(q, "Choose in style:\n", style=style)
print("You selected", m.get_selection())
print()
# --- theme defaults ---
CliMenu.set_default_cursor(CliMenuCursor.BULLET)
CliMenu.set_default_style(CliMenuTheme.BOLD_HIGHLIGHT)
q = ["Foo", "Bar", "Baz"]
m = CliMenu(q, "Time to choose:\n")
print("You selected", m.get_selection())
print()
# --- multiple headers ---
m = CliMenu()
m.add_header("Time to choose:\n", indent=False)
m.add_text("=== Category 1 ===")
m.add_option("Foo")
m.add_option("Bar")
m.add_option("Baz")
m.add_header('', indent=False)
m.add_text("=== Category 2 ===")
m.add_option("Cat 1")
m.add_option("Cat 2")
m.add_option("Cat 3")
print("You selected", m.get_selection())
print()
# --- with shortcut ---
try:
result = cli_select_item(["Foo", "Bar", "Baz"], abort_text="Selection faiiiled!")
print("You selected", result)
except ValueError as e:
print(e)
print()
# --- with shortcut, shows no menu when only a single option is provided (can be disabled with return_single=False) ---
result = cli_select_item(["Single Foo"])
print("Directly selected for you as it was the only option:", result)
print()
# --- prefix/suffix ---
q = ["Foo", "Bar", "Baz"]
m = CliMenu(q, "Time to choose:\n", option_prefix=' <<<', option_suffix='>>>')
print("You selected", m.get_selection())
print()
# --- colorize everything ---
m = CliMenu()
m.add_text("Time to choose:\n", style="green")
m.add_option("Yellow foo", style="yellow", highlighted_style="black bg:yellow")
m.add_option("Green bar", style="green", highlighted_style="black bg:green")
m.add_option("Blue baz", style="blue", highlighted_style="black bg:blue")
m.add_text("\n...go for it!")
print("You selected", m.get_selection())
print()

9
examples/cursorless.py Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env python3
from clintermission import CliMenu, CliMenuTheme
q = ["Foo", "Bar", "Baz baz baz baz baz"]
m = CliMenu(q, "Time to choose:\n", style=CliMenuTheme.BOLD_HIGHLIGHT,
cursor='', option_prefix=' ', option_suffix=' ', right_pad_options=True)
m.get_selection()
print("You selected", m.get_selection())

17
examples/multiselect.py Normal file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env python3
from clintermission import CliMultiMenu, CliSelectionStyle, CliMenuCursor
# --- simple multiselect ---
q = [
"Option 1",
"Option 2",
("Option 3 (preselected for your convenience)", "Option 3", True),
"Option 4"
]
m = CliMultiMenu(q, "Make your choice (<space> selects, <return> accepts):\n", cursor=CliMenuCursor.ASCII_ARROW,
selection_icons=CliSelectionStyle.CHECKMARK)
print("You selected", m.get_selection())
print("You selected num:", m.get_selection_num())
print("You selected item:", m.get_selection_item())

View File

@ -1,11 +1,13 @@
[metadata]
name = clintermission
version = 0.1.0
version = 0.2.0
author = Sebastian Lohff
author-email = seba@someserver.de
home-page = https://git.someserver.de/seba/clintermission
description = Non-fullscreen commandline selection menu
license = Apache-2.0
home-page = https://github.com/sebageek/clintermission
description = Non-fullscreen command-line selection menu
long-description = file: README.md
long-description-content-type = text/markdown
platform = any
keywords = cli, menu
classifiers =
@ -15,6 +17,7 @@ classifiers =
Programming Language :: Python :: 3.5
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
License :: OSI Approved :: Apache Software License
[options]