Skip to content

Commit 3ac28a6

Browse files
committed
Select menu implemented.
1 parent 2384282 commit 3ac28a6

File tree

2 files changed

+269
-13
lines changed

2 files changed

+269
-13
lines changed

awsshell/interaction.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
from abc import ABCMeta, abstractmethod
77

88
from prompt_toolkit import prompt
9-
from prompt_toolkit.contrib.completers import WordCompleter, PathCompleter
10-
9+
from prompt_toolkit.contrib.completers import PathCompleter
1110
from awsshell.utils import FSLayer
11+
from awsshell.selectmenu import select_prompt
1212

1313

1414
class InteractionException(Exception):
@@ -66,26 +66,20 @@ class SimpleSelect(Interaction):
6666
used as is.
6767
"""
6868

69-
def __init__(self, model, prompt_msg, prompter=prompt):
69+
def __init__(self, model, prompt_msg, prompter=select_prompt):
7070
super(SimpleSelect, self).__init__(model, prompt_msg)
7171
self._prompter = prompter
7272

7373
def execute(self, data):
7474
if not isinstance(data, list) or len(data) < 1:
7575
raise InteractionException('SimpleSelect expects a non-empty list')
7676
if self._model.get('Path') is not None:
77-
display_data = jmespath.search(self._model['Path'], data)
78-
completer = WordCompleter(
79-
display_data,
80-
sentence=True,
81-
ignore_case=True
82-
)
83-
option_dict = dict(zip(display_data, data))
84-
selected = self._prompter('%s ' % self.prompt, completer=completer)
77+
disp_data = jmespath.search(self._model['Path'], data)
78+
option_dict = dict(zip(disp_data, data))
79+
selected = self._prompter('%s ' % self.prompt, disp_data)
8580
return option_dict[selected]
8681
else:
87-
cmpltr = WordCompleter(data, ignore_case=True, sentence=True)
88-
return self._prompter('%s ' % self.prompt, completer=cmpltr)
82+
return self._prompter('%s ' % self.prompt, data)
8983

9084

9185
class SimplePrompt(Interaction):

awsshell/selectmenu.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import json
2+
from pygments.lexers import find_lexer_class
3+
from prompt_toolkit.keys import Keys
4+
from prompt_toolkit.token import Token
5+
from prompt_toolkit.filters import IsDone
6+
from prompt_toolkit.utils import get_cwidth
7+
from prompt_toolkit.document import Document
8+
from prompt_toolkit.enums import DEFAULT_BUFFER
9+
from prompt_toolkit.interface import Application
10+
from prompt_toolkit.filters import to_simple_filter
11+
from prompt_toolkit.layout.screen import Point, Char
12+
from prompt_toolkit.shortcuts import run_application
13+
from prompt_toolkit.layout.lexers import PygmentsLexer
14+
from prompt_toolkit.layout.prompt import DefaultPrompt
15+
from prompt_toolkit.buffer import Buffer, AcceptAction
16+
from prompt_toolkit.layout.controls import BufferControl
17+
from prompt_toolkit.layout.margins import ScrollbarMargin
18+
from prompt_toolkit.layout.dimension import LayoutDimension
19+
from prompt_toolkit.key_binding.manager import KeyBindingManager
20+
from prompt_toolkit.layout.controls import UIControl, UIContent, FillControl
21+
from prompt_toolkit.layout import Window, HSplit, FloatContainer, Float
22+
from prompt_toolkit.layout.containers import ScrollOffsets, \
23+
ConditionalContainer
24+
25+
26+
class SelectMenuControl(UIControl):
27+
"""Given a list, display that list as a drop down menu."""
28+
29+
# 7 because that's what the default prompt uses
30+
MIN_WIDTH = 7
31+
32+
def __init__(self, options):
33+
self.token = Token.Menu.Completions
34+
self._options = options
35+
# Add two to give the list some breathing room
36+
self.width = max(get_cwidth(o) for o in self._options) + 2
37+
self.width = max(self.width, self.MIN_WIDTH)
38+
self.height = len(options)
39+
self._selection = None
40+
41+
def get_selection(self):
42+
"""Return the currently selected option, if there is one."""
43+
if self._selection is not None:
44+
return self._options[self._selection]
45+
else:
46+
return None
47+
48+
def select_down(self, event=None):
49+
"""Cycle down the list of options by one."""
50+
if self._selection is not None:
51+
self._selection = (self._selection + 1) % self.height
52+
else:
53+
self._selection = 0
54+
self._insert_text(event)
55+
56+
def select_up(self, event=None):
57+
"""Cycle up the list of options by one."""
58+
if self._selection is not None:
59+
self._selection -= 1
60+
if self._selection < 0:
61+
self._selection = self.height - 1
62+
else:
63+
self._selection = self.height - 1
64+
self._insert_text(event)
65+
66+
def _insert_text(self, event):
67+
if event is not None:
68+
event.current_buffer.document = Document(self.get_selection())
69+
70+
def preferred_width(self, cli, max_available_width):
71+
"""Return the preferred width of this UIControl."""
72+
return self.width
73+
74+
def preferred_height(self, cli, width, max_available_height, wrap_lines):
75+
"""Return the preferred height of this UIControl."""
76+
return self.height
77+
78+
def create_content(self, cli, width, height):
79+
"""Generate the UIContent for this control.
80+
81+
Create a get_line function that returns how each line of this control
82+
should be rendered.
83+
"""
84+
def get_line(i):
85+
c = self._options[i]
86+
is_current = (i == self._selection)
87+
# Render the line as array of tokens for highlighting
88+
return self._get_menu_item_tokens(c, is_current)
89+
90+
return UIContent(
91+
get_line=get_line,
92+
line_count=self.height,
93+
default_char=Char(' ', self.token),
94+
cursor_position=Point(x=0, y=self._selection or 0)
95+
)
96+
97+
def _get_menu_item_tokens(self, option, is_current):
98+
"""Given an option generate the proper token list for highlighting."""
99+
# highlight the current selection with a different token
100+
if is_current:
101+
token = self.token.Completion.Current
102+
else:
103+
token = self.token.Completion
104+
# pad all lines to the same width
105+
padding = ' ' * (self.width - len(option))
106+
return [(token, ' %s%s ' % (option, padding))]
107+
108+
109+
def create_select_menu_layout(msg, menu_control,
110+
show_meta=False,
111+
reserve_space_for_menu=True):
112+
"""Construct a layout for the given message and menu control."""
113+
def get_prompt_tokens(_):
114+
return [(Token.Prompt, msg)]
115+
116+
# Ensures that the menu has enough room to display it's options
117+
def get_prompt_height(cli):
118+
if reserve_space_for_menu and not cli.is_done:
119+
return LayoutDimension(min=8)
120+
else:
121+
return LayoutDimension()
122+
123+
input_processors = [DefaultPrompt(get_prompt_tokens)]
124+
prompt_layout = FloatContainer(
125+
HSplit([
126+
Window(
127+
BufferControl(input_processors=input_processors),
128+
get_height=get_prompt_height
129+
)
130+
]),
131+
[
132+
Float(
133+
# Display the prompt starting below the cursor
134+
left=len(msg),
135+
ycursor=True,
136+
content=ConditionalContainer(
137+
content=Window(
138+
content=menu_control,
139+
# only display 1 - 7 lines of completions
140+
height=LayoutDimension(min=1, max=7),
141+
# display a scroll bar
142+
scroll_offsets=ScrollOffsets(top=0, bottom=0),
143+
right_margins=[ScrollbarMargin()],
144+
# don't make the menu wider than the options
145+
dont_extend_width=True
146+
),
147+
# Only display the prompt while the buffer is relevant
148+
filter=~IsDone()
149+
)
150+
)
151+
]
152+
)
153+
154+
# Show meta information with the buffer isn't done and show_meta is True
155+
meta_filter = ~IsDone() & to_simple_filter(show_meta)
156+
return HSplit([
157+
prompt_layout,
158+
ConditionalContainer(
159+
filter=meta_filter,
160+
content=Window(
161+
height=LayoutDimension.exact(1),
162+
content=FillControl(u'\u2500', token=Token.Line)
163+
)
164+
),
165+
ConditionalContainer(
166+
filter=meta_filter,
167+
content=Window(
168+
# Meta information takes up at most 15 lines
169+
height=LayoutDimension(max=15),
170+
content=BufferControl(
171+
buffer_name='INFO',
172+
# TODO discuss pygments import voodoo
173+
lexer=PygmentsLexer(find_lexer_class('JSON'))
174+
)
175+
),
176+
)
177+
])
178+
179+
180+
class SelectMenuApplication(Application):
181+
"""Wrap Application, providing the correct layout, keybindings, etc."""
182+
183+
def __init__(self, message, options, *args, **kwargs):
184+
self._menu_control = SelectMenuControl(options)
185+
186+
self.kb_manager = KeyBindingManager(
187+
enable_system_bindings=True,
188+
enable_abort_and_exit_bindings=True
189+
)
190+
menu_control = self._menu_control
191+
options_meta = kwargs.pop('options_meta', None)
192+
193+
# Return the currently selected option
194+
def return_selection(cli, buf):
195+
cli.set_return_value(menu_control.get_selection())
196+
197+
buffers = {}
198+
199+
def_buf = Buffer(
200+
initial_document=Document(''),
201+
accept_action=AcceptAction(return_selection)
202+
)
203+
204+
buffers[DEFAULT_BUFFER] = def_buf
205+
206+
show_meta = options_meta is not None
207+
# Optionally show meta information if present
208+
if show_meta:
209+
info_buf = Buffer(is_multiline=True)
210+
buffers['INFO'] = info_buf
211+
212+
def selection_changed(cli):
213+
info = options_meta[buffers[DEFAULT_BUFFER].text]
214+
formatted_info = json.dumps(info, indent=4, sort_keys=True)
215+
buffers['INFO'].text = formatted_info
216+
def_buf.on_text_changed += selection_changed
217+
218+
# Apply the correct buffers, key bindings, and layout before super call
219+
kwargs['buffers'] = buffers
220+
kwargs['key_bindings_registry'] = self.kb_manager.registry
221+
kwargs['layout'] = create_select_menu_layout(
222+
message,
223+
menu_control,
224+
show_meta=show_meta
225+
)
226+
self._bind_keys(self.kb_manager.registry, self._menu_control)
227+
super(SelectMenuApplication, self).__init__(*args, **kwargs)
228+
229+
def _bind_keys(self, registry, menu_control):
230+
handle = registry.add_binding
231+
232+
@handle(Keys.F10)
233+
def handle_f10(event):
234+
event.cli.set_exit()
235+
236+
@handle(Keys.Up)
237+
@handle(Keys.BackTab)
238+
def handle_up(event):
239+
menu_control.select_up(event=event)
240+
241+
@handle(Keys.Tab)
242+
@handle(Keys.Down)
243+
def handle_down(event):
244+
menu_control.select_down(event=event)
245+
246+
@handle(Keys.ControlJ)
247+
def accept(event):
248+
buff = event.current_buffer
249+
selection = menu_control.get_selection()
250+
if selection is not None:
251+
buff.accept_action.validate_and_handle(event.cli, buff)
252+
253+
@handle(Keys.Any)
254+
@handle(Keys.Backspace)
255+
def _(_):
256+
pass
257+
258+
259+
def select_prompt(message, options, *args, **kwargs):
260+
"""Construct and run the select menu application, returning the result."""
261+
app = SelectMenuApplication(message, options, *args, **kwargs)
262+
return run_application(app)

0 commit comments

Comments
 (0)