Compare commits

...

9 Commits

Author SHA1 Message Date
Sebastian Lohff 648d5c956e Use option text as title if no value is given
If the user only supplys a text for the option we used to return None as
item value. Now we return the text, so the user can easily get the value
back if they want to process it further and not only work with the given
option index (or supply the item twice).
2020-03-11 18:19:19 +01:00
Sebastian Lohff 8c6ee790bf Fix get_options() and num_options to work on options
get_options() and num_options used to work on all menu items, including
blank spaces and headers. Now these work only on options, as they were
originally intended
2020-03-11 18:18:26 +01:00
Sebastian Lohff 9fe596bc01 Add option to return instantly on single item in cli_select_item()
cli_select_item() allows to quickly launch a menu from code, but if only
a single item is given a selection is not necessary in most cases.
Therefore we now instantly return the given value in these cases. This
can be turned off by setting return_single=False
2020-03-11 17:55:16 +01:00
Sebastian Lohff d926606976 Add shortcut function to quickly get a selection result
cli_select_item() will instanciate a CliMenu and return a selection. If
the selection is aborted an exception of the given class will be thrown.
2020-01-23 09:59:55 +01:00
Sebastian Lohff 3d1d2d5acd Add theme example 2019-08-15 15:13:18 +02:00
Sebastian Lohff 58d6170a5c Add bold highlight style to defaults 2019-08-15 15:10:21 +02:00
Sebastian Lohff 206dcc46df Correct typo in var name 2019-08-15 15:10:07 +02:00
Sebastian Lohff 58455be58a Add methods to get all available menu options 2019-07-18 13:32:23 +02:00
Sebastian Lohff f0e5b7bb58 Implement searching for menu items
This uses prompt-toolkit's builtin search functionality to search
through the buffer representing the menu, allowing the user
to navigate faster through the entries
2019-04-22 14:35:38 +02:00
3 changed files with 117 additions and 17 deletions

View File

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

View File

@ -3,10 +3,12 @@
from prompt_toolkit.application import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.document import Document
from prompt_toolkit.filters import is_searching
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import Layout, Window, HSplit
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.layout.processors import Processor, Transformation
from prompt_toolkit import search
from prompt_toolkit.widgets import SearchToolbar
@ -66,10 +68,11 @@ class CliMenuTheme:
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')
class CliMenu:
default_stye = CliMenuTheme.BASIC
default_style = CliMenuTheme.BASIC
default_cursor = CliMenuCursor.TRIANGLE
def __init__(self, options=None, header=None, cursor=None, style=None,
@ -89,17 +92,17 @@ class CliMenu:
self._style = style
if not self._style:
self._style = self.default_stye
self._style = self.default_style
if header:
self.add_header(header, indent=False)
if options:
for option in options:
if isinstance(option, tuple):
if isinstance(option, tuple) and len(option) == 2:
self.add_option(*option)
else:
self.add_option(option)
self.add_option(option, option)
def add_header(self, title, indent=True):
for text in title.split('\n'):
@ -116,6 +119,13 @@ class CliMenu:
return self._success
def get_options(self):
return [_item for _item in self._items if isinstance(_item, CliMenuOption)]
@property
def num_options(self):
return self._item_num
def get_selection(self):
if self.success:
item = self._items[self._pos]
@ -139,6 +149,8 @@ class CliMenu:
return ' ' * (len(self._cursor) + 1 * self._dedent_selection)
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
@ -160,7 +172,9 @@ class CliMenu:
indent += ' ' * (self._header_indent + len(self._cursor) + 1)
style = s.header_style
return Transformation([('', indent), (style, prefix + text)])
items = [(s if s else style, t) for s, t in ti.fragments]
return Transformation([('', indent), (style, prefix)] + items)
def next_item(self, direction):
if not any(item.focusable for item in self._items):
@ -175,6 +189,38 @@ class CliMenu:
if self._items[self._pos].focusable:
break
def sync_cursor_to_line(self, line, sync_dir=1):
"""Sync cursor to next fousable item starting on `line`"""
assert sync_dir in (1, -1)
self._pos = line
while not self._items[self._pos].focusable:
self._pos = (self._pos + sync_dir) % len(self._items)
self._buf.cursor_position = self._doc.translate_row_col_to_index(self._pos, 0)
def _get_search_result_lines(self):
"""Get a list of all lines that have a match with the current search result"""
if not self._bufctrl.search_state.text:
return []
idx_list = []
i = 1
while True:
next_idx = self._buf.get_search_position(self._bufctrl.search_state, count=i,
include_current_position=False)
if next_idx in idx_list:
break
idx_list.append(next_idx)
i += 1
lines = []
for idx in idx_list:
line, _ = self._doc.translate_index_to_position(idx)
if line not in lines:
lines.append(line)
return lines
def _run(self):
class MenuColorizer(Processor):
def apply_transformation(_self, ti):
@ -183,40 +229,62 @@ class CliMenu:
# keybindings
self._kb = KeyBindings()
@self._kb.add('q')
@self._kb.add('q', filter=~is_searching)
@self._kb.add('c-c')
def quit(event):
event.app.exit()
@self._kb.add('down')
@self._kb.add('j')
@self._kb.add('down', filter=~is_searching)
@self._kb.add('j', filter=~is_searching)
def down(event):
self.next_item(1)
@self._kb.add('up')
@self._kb.add('k')
@self._kb.add('up', filter=~is_searching)
@self._kb.add('k', filter=~is_searching)
def up(event):
self.next_item(-1)
@self._kb.add('right')
@self._kb.add('c-m')
@self._kb.add('c-space')
@self._kb.add('N', filter=~is_searching)
@self._kb.add('n', filter=~is_searching)
def search_inc(event, filter=is_searching):
if not self._bufctrl.search_state.text:
return
search_dir = 1 if event.data == 'n' else -1
sr_lines = self._get_search_result_lines()
if sr_lines:
line = sr_lines[search_dir] if len(sr_lines) > 1 else sr_lines[0]
self.sync_cursor_to_line(line, search_dir)
@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._kb.add('c-m', filter=is_searching)
def accept_search(event):
search.accept_search()
new_line, _ = self._doc.translate_index_to_position(self._buf.cursor_position)
self.sync_cursor_to_line(new_line)
self._searchbar = SearchToolbar(ignore_case=True)
text = '\n'.join(map(lambda _x: _x.text, self._items))
self._doc = Document(text, cursor_position=self._pos)
self._buf = Buffer(read_only=True, document=self._doc)
self._bufctrl = BufferControl(self._buf,
search_buffer_control=self._searchbar.control,
preview_search=True,
input_processors=[MenuColorizer()])
split = HSplit([Window(self._bufctrl,
wrap_lines=True,
always_hide_cursor=True)])
always_hide_cursor=True),
self._searchbar])
# set initial pos
if not self._items[self._pos].focusable:
self.next_item(1)
self.sync_cursor_to_line(0)
app = Application(layout=Layout(split),
key_bindings=self._kb,
@ -225,3 +293,18 @@ class CliMenu:
app.run()
self._ran = True
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"""
menu = CliMenu(header=header, options=options, style=style)
if return_single and menu.num_options == 1:
item = menu.get_options()[0]
return item.num, item.item
if not menu.success:
raise abort_exc(abort_text)
return menu.get_selection()

16
examples/theme1.py Normal file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
from clintermission import CliMenu, CliMenuTheme
def main():
q = ["Foo", "Bar", "Baz baz baz baz baz"]
m = CliMenu(q, "Time to choose:\n", style=CliMenuTheme.BOLD_HIGHLIGHT)
if m.success:
print("You selected", m.get_selection())
else:
print("You aborted the selection")
if __name__ == '__main__':
main()