Created
August 8, 2020 17:20
Custom `inquirer.ListBox` component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""Custom `inquirer.ListBox` component | |
With this component, we can perform selecting with choices on terminal | |
without lefting those printed lines stacking above the cursor. | |
The idea of this implementation is that we have to know have many lines | |
have been printed, and then we can move the cursor to that line and clear | |
those lines under it. | |
If you are in a hurry for the approach applied here, you can just look | |
at the implementation of: | |
- `ListBoxRender._relocate()`: | |
There is the trick how we erase lines. | |
- `ListBoxRender._print_options()`: | |
We add a counter in it to count how many lines of the `options` | |
will be printed. | |
- `ListBoxRender._print_header()`: | |
What we do in this is just the reason as mentioned above. | |
You can check out those code with comment below for further details. | |
Besides, there is another implementation below using whitespaces to | |
overwrite those printed lines. The reason why it is done in this way | |
is to avoid those characters not able to be overwritten by newly printed | |
content. | |
Since I am not an expert of `curses` or `blessed`, there is no guarantee | |
that there is no side effect in this implementation. But feel free to let | |
me know if there is something wrong with it. | |
And also feel free to use or modify it, have fun! | |
- Demo: | |
https://i.imgur.com/6a9hgNy.gif | |
- Related issue: | |
https://github.com/magmax/python-inquirer/issues/57 | |
""" | |
# This is required to do on Windows, otherwise, we cannot erase printed | |
# lines by the trick. | |
import os | |
term = os.getenv('TERM', None) | |
if term == 'cygwin': | |
os.environ['TERM'] = 'xterm' | |
from inquirer import errors | |
from inquirer.render.console import ( | |
# Here we need to import these for `ListBoxRender.render_factory()` | |
Text, Editor, Password, Confirm, List, Checkbox, Path, | |
ConsoleRender as _ConsoleRender, | |
) | |
from inquirer.render.console.base import MAX_OPTIONS_DISPLAYED_AT_ONCE | |
from inquirer.questions import List as ListQuestion | |
from inquirer import prompt as inquirer_prompt | |
class ListBoxRender(_ConsoleRender): | |
def __init__(self, event_generator=None, theme=None, *args, **kwargs): | |
self.render_config = kwargs.pop('render_config', {}) | |
self._printed_lines = 0 | |
self.message_handler = self.render_config.pop('message_handler', None) | |
super(ListBoxRender, self).__init__(*args, **kwargs) | |
def render(self, question, answers=None): | |
question.answers = answers or {} | |
if question.ignore: | |
return question.default | |
clazz = self.render_factory(question.kind) | |
render = clazz( | |
question, | |
terminal=self.terminal, | |
theme=self._theme, | |
show_default=question.show_default, | |
render_config=self.render_config, # customized feature | |
) | |
self.clear_eos() | |
try: | |
return self._event_loop(render) | |
finally: | |
print('') | |
def render_factory(self, question_type): | |
matrix = { | |
'text': Text, | |
'editor': Editor, | |
'password': Password, | |
'confirm': Confirm, | |
'list': List, | |
'listbox': ListBox, # added for custom `ListBox` question | |
'checkbox': Checkbox, | |
'path': Path, | |
} | |
if question_type not in matrix: | |
raise errors.UnknownQuestionTypeError() | |
return matrix.get(question_type) | |
# added by us | |
def _count_lines(self, msg): | |
self._printed_lines += msg.count('\n') + 1 | |
# added by us | |
def _reset_counter(self): | |
self._printed_lines = 0 | |
def _print_options(self, render): | |
for message, symbol, color in render.get_options(): | |
if self.message_handler is not None: | |
message = self.message_handler(message) | |
if hasattr(message, 'decode'): | |
message = message.decode('utf-8') | |
self._count_lines(message) # count lines to be erased | |
self.print_line(' {color}{s} {m}{t.normal}', | |
m=message, color=color, s=symbol) | |
def _print_header(self, render): | |
base = render.get_header() | |
header = (base[:self.width - 9] + '...' | |
if len(base) > self.width - 6 | |
else base) | |
default_value = ' ({color}{default}{normal})'.format( | |
default=render.question.default, | |
color=self._theme.Question.default_color, | |
normal=self.terminal.normal | |
) | |
show_default = render.question.default and render.show_default | |
header += default_value if show_default else '' | |
msg_template = "{t.move_up}{t.clear_eol}{tq.brackets_color}["\ | |
"{tq.mark_color}?{tq.brackets_color}]{t.normal} {msg}" | |
self._count_lines(header) # count lines to be erased | |
self.print_str( | |
'\n%s:' % (msg_template), | |
msg=header, | |
lf=not render.title_inline, | |
tq=self._theme.Question) | |
def _relocate(self): | |
# Just get the control code beforehand | |
move_up, clear_eol = self.terminal.move_up(), self.terminal.clear_eol() | |
# Note that we have to clear lines one by one, so that this function | |
# is made for duplicate the control code | |
clear_lines = lambda n: (move_up + clear_eol) * n | |
if self._printed_lines > 0: | |
# Generate control code according to the number of printed lines | |
term_refresh_code = clear_lines(self._printed_lines) | |
# Print out the control code to erase lines | |
print(term_refresh_code, end='', flush=True) | |
self._reset_counter() | |
self._force_initial_column() | |
self._position = 0 | |
# ----- You can also try this implementation ----- | |
# def _relocate(self): | |
# # NOTE: Since `self.width` indicating the full-width of current terminal, | |
# # it's unnecessary to add a newline character after it. Otherwise, one | |
# # more blank line will be printed. | |
# # To see how this trick works, you can replace the space in `empty_line` | |
# # by any other visible character and run it. | |
# empty_line = '%s' % (' ' * self.width) | |
# if self._printed_lines > 0: | |
# # What we are going to do is: | |
# # 1. Move cursor to the original position where options are going | |
# # to be rendered. | |
# # 2. *Refresh* the area with printed content by empty lines. | |
# # I was considering using `self.terminal.clear()` | |
# # (this is: `blessed.Terminal().clear()`) to clear the screen, | |
# # but it would clear the whole history of current terminal. It's | |
# # not my desired result so I choose this approach instead. | |
# # 3. Since empty lines are printed, the cursor is now moved. We have | |
# # to move it back to the top. | |
# term_refresh_code = ( | |
# self.terminal.move_up(self._printed_lines) # 1 | |
# + empty_line * self._printed_lines # 2 | |
# + self.terminal.move_up(self._printed_lines) # 3 | |
# ) | |
# print(term_refresh_code, end='', flush=True) | |
# self._reset_counter() | |
# self._force_initial_column() | |
# self._position = 0 | |
class ListBox(List): | |
def __init__(self, *args, **kwargs): | |
render_config = kwargs.pop('render_config', {}) # custom feature | |
self.max_options_in_display = render_config.get( | |
'n_max_options', MAX_OPTIONS_DISPLAYED_AT_ONCE | |
) | |
self.half_options = int((self.max_options_in_display - 1) / 2) | |
super(ListBox, self).__init__(*args, **kwargs) | |
@property | |
def is_long(self): | |
choices = self.question.choices or [] | |
return len(choices) >= self.max_options_in_display # renamed | |
def get_options(self): | |
# In order to control the maximal number of choices to show on terminal, | |
# we have to override this method and rename every `MAX_OPTIONS_DISPLAYED_AT_ONCE` | |
# and `half_options` to `self.max_options_in_display` and `self.half_options`. | |
choices = self.question.choices or [] | |
if self.is_long: | |
cmin = 0 | |
cmax = self.max_options_in_display | |
if self.half_options < self.current < len(choices) - self.half_options: | |
cmin += self.current - self.half_options | |
cmax += self.current - self.half_options | |
elif self.current >= len(choices) - self.half_options: | |
cmin += len(choices) - self.max_options_in_display | |
cmax += len(choices) | |
cchoices = choices[cmin:cmax] | |
else: | |
cchoices = choices | |
ending_milestone = max(len(choices) - self.half_options, self.half_options + 1) | |
is_in_beginning = self.current <= self.half_options | |
is_in_middle = self.half_options < self.current < ending_milestone | |
is_in_end = self.current >= ending_milestone | |
for index, choice in enumerate(cchoices): | |
end_index = ending_milestone + index - self.half_options - 1 | |
if (is_in_middle and index == self.half_options) \ | |
or (is_in_beginning and index == self.current) \ | |
or (is_in_end and end_index == self.current): | |
color = self.theme.List.selection_color | |
symbol = self.theme.List.selection_cursor | |
else: | |
color = self.theme.List.unselected_color | |
symbol = ' ' | |
yield choice, symbol, color | |
class ListBoxQuestion(ListQuestion): | |
kind = 'listbox' | |
def __init__(self, name, **kwargs): | |
super(ListBoxQuestion, self).__init__(name, **kwargs) | |
# ----- Things for using those component above ----- | |
from datetime import datetime as dt | |
import textwrap as tw | |
class Note(object): | |
def __init__(self, title, content, create_time): | |
self.title = title | |
self.content = content | |
self.create_time = create_time | |
def __repr__(self): | |
return '<Note object, title: %s>' % self.title | |
@classmethod | |
def create(cls, title, content): | |
return cls(title, content, dt.now().timestamp()) | |
class NoteFormatter(object): | |
def __init__(self, **kwargs): | |
self.config = { | |
'width': kwargs.get('width', 70), | |
'tabsize': kwargs.get('tabsize', 4), | |
'replace_whitespace': kwargs.get('replace_whitespace', False), | |
'drop_whitespace': kwargs.get('drop_whitespace', True), | |
'max_lines': kwargs.get('max_lines', 3), | |
'placeholder': kwargs.get('placeholder', '...'), | |
'initial_indent': kwargs.get('initial_indent', ' '), | |
'subsequent_indent': kwargs.get('subsequent_indent', ' '), | |
} | |
self.text_wrapper = tw.TextWrapper(**self.config) | |
self._prepare_formatter() | |
def _prepare_formatter(self): | |
text_shortener = lambda text: tw.shorten( | |
text, width=self.config['width'], placeholder='...' | |
) | |
text_indenter = lambda text: '\n'.join(self.text_wrapper.wrap(text)) | |
fmt_title = lambda x: 'Title: %s' % text_shortener(x.title) | |
fmt_content = lambda x: 'Content:\n%s' % text_indenter(x.content) | |
fmt_create_time = lambda x: 'Created: %s' % ( | |
dt.fromtimestamp(x.create_time).strftime('%Y-%m-%d %H:%M:%S') | |
) | |
self.formatters = [fmt_title, fmt_create_time, fmt_content] | |
def __call__(self, note): | |
return '\n'.join([fmt(note) for fmt in self.formatters]) | |
def main(): | |
fmt_title = '# title {idx}' | |
content = 'quick brown fox jumps over the lazy dog ' | |
num_notes = 10 | |
notes = [] | |
for idx in range(num_notes): | |
# here we are just duplicating the content | |
notes.append(Note.create(fmt_title.format(idx=idx), content*idx)) | |
formatter = NoteFormatter() | |
def message_handler(message): | |
# NOTE: `message` is a instance of `TaggedValue` if choices of question | |
# are tuples. See also: | |
# https://github.com/magmax/python-inquirer/blob/5412d53/inquirer/questions.py#L111-L117 | |
return '\n%s\n' % formatter(message.value) | |
questions = [ | |
ListBoxQuestion( | |
'note', | |
message='Select a note', | |
choices=[(i, v) for i, v in enumerate(notes)], | |
) | |
] | |
render_config = { | |
'n_max_options': 5, | |
'message_handler': message_handler, | |
} | |
render = ListBoxRender(render_config=render_config) | |
retval = inquirer_prompt(questions, render=render) | |
print('---------- selected result ----------') | |
print(retval) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is how it looks like: