-
Notifications
You must be signed in to change notification settings - Fork 0
/
listmenu.py
312 lines (246 loc) · 10 KB
/
listmenu.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
from dataclasses import dataclass
from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.filters import Condition, IsDone
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.controls import (
FormattedTextControl, GetLinePrefixCallable, UIContent, UIControl
)
from prompt_toolkit.layout.containers import ConditionalContainer, HSplit, Window
from prompt_toolkit.layout.dimension import LayoutDimension as D
import string
from typing import Generic, List, Optional, TypeVar
from .common import default_style
T = TypeVar('T')
class Separator:
""" Used just as a type. Not supposed to be instantiated """
@dataclass
class Choice(Generic[T]):
display_text: str
value: T
disabled_reason: Optional[str] = None
@property
def is_disabled(self) -> bool:
return self.disabled_reason is not None
@property
def display_length(self) -> int:
return len(self.display_text)
@staticmethod
def separator() -> 'Choice':
return Choice('-' * 15, Separator, '')
@property
def is_separator(self) -> bool:
return self.value == Separator
@staticmethod
def from_string(value: str) -> 'Choice':
return Choice(value, value, None)
class ChoicesControl(UIControl):
"""
Menu to display some textual choices.
Provide a search feature by just typing the start of the entry desired
"""
def __init__(self, choices: List[Choice], **kwargs):
# Selection to keep consistent
self._selected_choice: Optional[Choice] = None
self._selected_index: int = -1
self._answered = False
self._search_string: Optional[str] = None
self._choices = choices
self._cached_choices: Optional[List[Choice]] = None
self._init_choices(default=kwargs.pop('default'))
super().__init__(**kwargs)
def _init_choices(self, default=None):
if default is not None and default not in self._choices:
raise ValueError(f"Default value {default} is not part of the given choices")
self._compute_available_choices(default=default)
@property
def is_answered(self) -> bool:
return self._answered
@is_answered.setter
def is_answered(self, value: bool) -> None:
self._answered = value
def _get_available_choices(self) -> List[Choice]:
if self._cached_choices is None:
self._compute_available_choices()
return self._cached_choices or []
def _compute_available_choices(self, default: Optional[Choice] = None) -> None:
self._cached_choices = []
for choice in self._choices:
if self._search_string:
if choice.display_text.startswith(self._search_string):
self._cached_choices.append(choice)
else:
self._cached_choices.append(choice)
if self._cached_choices == []:
self._selected_choice = None
self._selected_index = -1
else:
if default is not None:
self._selected_choice = default
self._selected_index = self._cached_choices.index(default)
if self._selected_choice not in self._cached_choices:
self._selected_choice = self._cached_choices[0]
self._selected_index = 0
while self._selected_choice.is_disabled:
self.select_next_choice()
def _reset_cached_choices(self) -> None:
self._cached_choices = None
def get_selection(self):
return self._selected_choice
def select_next_choice(self) -> None:
if not self._cached_choices or self._selected_choice is None:
return
def _next():
self._selected_index += 1
self._selected_choice = self._cached_choices[self._selected_index % self.choice_count]
_next()
while self._selected_choice.is_disabled:
_next()
def select_previous_choice(self) -> None:
if not self._cached_choices or self._selected_choice is None:
return
def _prev():
self._selected_index -= 1
self._selected_choice = self._cached_choices[self._selected_index % self.choice_count]
_prev()
while self._selected_choice.is_disabled:
_prev()
def preferred_width(self, max_available_width: int) -> int:
max_elem_width = max(list(map(lambda x: x.display_length, self._choices)))
return min(max_elem_width, max_available_width)
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix: Optional[GetLinePrefixCallable],
) -> Optional[int]:
return self.choice_count
def create_content(self, width: int, height: int) -> UIContent:
def _get_line_tokens(line_number):
choice = self._get_available_choices()[line_number]
tokens = []
selected = (choice == self.get_selection())
if selected:
tokens.append(('class:set-cursor-position', ' \u276f '))
else:
# For alignment
tokens.append(('', ' '))
if choice.is_disabled:
token_text = choice.display_text
if choice.disabled_reason:
token_text += f' ({choice.disabled_reason})'
tokens.append(('class:selected' if selected else 'class:disabled', token_text))
else:
tokens.append(('class:selected' if selected else '', str(choice.display_text)))
return tokens
return UIContent(
get_line=_get_line_tokens,
line_count=self.choice_count,
)
@property
def choice_count(self):
return len(self._get_available_choices())
def get_search_string_tokens(self):
if self._search_string is None:
return None
return [
('', '\n'),
('class:question-mark', '/ '),
('class:search', self._search_string),
('class:question-mark', '...'),
]
def append_to_search_string(self, char: str) -> None:
""" Appends a character to the search string """
if self._search_string is None:
self._search_string = ''
self._search_string += char
self._reset_cached_choices()
def remove_last_char_from_search_string(self) -> None:
""" Remove the last character from the search string (~backspace) """
if self._search_string and len(self._search_string) > 1:
self._search_string = self._search_string[:-1]
else:
self._search_string = None
self._reset_cached_choices()
def reset_search_string(self) -> None:
self._search_string = None
def question(message, choices: List[Choice], default=None, qmark='?', key_bindings=None, **kwargs):
"""
Builds a `prompt-toolkit` Application that display a list of choices (ChoiceControl) along with
search features and key bindings
Paramaters
==========
kwargs: Dict[Any, Any]
Any additional arguments that a prompt_toolkit.application.Application can take. Passed
as-is
"""
if key_bindings is None:
key_bindings = KeyBindings()
choices_control = ChoicesControl(choices, default=default)
def get_prompt_tokens():
tokens = []
tokens.append(('class:question-mark', qmark))
tokens.append(('class:question', ' %s ' % message))
if choices_control.is_answered:
tokens.append(('class:answer', ' ' + choices_control.get_selection().display_text))
else:
tokens.append(('class:instruction', ' (Use arrow keys)'))
return tokens
@Condition
def has_search_string():
return choices_control.get_search_string_tokens is not None
@key_bindings.add(Keys.ControlQ, eager=True)
def exit_menu(event):
event.app.exit(exception=KeyboardInterrupt())
if not key_bindings.get_bindings_for_keys((Keys.ControlC,)):
key_bindings.add(Keys.ControlC, eager=True)(exit_menu)
@key_bindings.add(Keys.Down, eager=True)
def move_cursor_down(_event): # pylint:disable=unused-variable
choices_control.select_next_choice()
@key_bindings.add(Keys.Up, eager=True)
def move_cursor_up(_event): # pylint:disable=unused-variable
choices_control.select_previous_choice()
@key_bindings.add(Keys.Enter, eager=True)
def set_answer(event): # pylint:disable=unused-variable
choices_control.is_answered = True
choices_control.reset_search_string()
event.app.exit(result=choices_control.get_selection().value)
def search_filter(event):
choices_control.append_to_search_string(event.key_sequence[0].key)
for character in string.printable:
key_bindings.add(character, eager=True)(search_filter)
@key_bindings.add(Keys.Backspace, eager=True)
def delete_from_search_filter(_event): # pylint:disable=unused-variable
choices_control.remove_last_char_from_search_string()
layout = Layout(
HSplit([
# Question
Window(
height=D.exact(1),
content=FormattedTextControl(get_prompt_tokens),
always_hide_cursor=True,
),
# Choices
ConditionalContainer(
Window(choices_control),
filter=~IsDone() # pylint:disable=invalid-unary-operand-type
),
# Searched string
ConditionalContainer(
Window(
height=D.exact(2),
content=FormattedTextControl(choices_control.get_search_string_tokens)
),
filter=has_search_string & ~IsDone() # pylint:disable=invalid-unary-operand-type
),
])
)
return Application(
layout=layout,
key_bindings=key_bindings,
mouse_support=False,
style=default_style,
**kwargs
)