33
44from abc import ABC
55from dataclasses import dataclass
6- from functools import partial
7- from typing import Any , Callable , List , Optional
8- from unittest .mock import patch
6+ from typing import Any , List , Optional , Tuple
97
108from prompt_toolkit .application import Application
119from prompt_toolkit .buffer import Buffer
1412from prompt_toolkit .formatted_text .base import StyleAndTextTuples
1513from prompt_toolkit .key_binding import KeyBindings
1614from prompt_toolkit .layout import HSplit , Layout , Window
15+ from prompt_toolkit .layout .containers import VerticalAlign
1716from prompt_toolkit .layout .controls import BufferControl
17+ from prompt_toolkit .layout .dimension import Dimension
1818from prompt_toolkit .layout .processors import Processor , Transformation , TransformationInput
1919from prompt_toolkit .search import SearchState
2020from prompt_toolkit .widgets import SearchToolbar
2121
2222from ._messenger import Messenger
2323
2424
25- class _LineRenderProcessor (Processor ):
25+ class _ItemRenderProcessor (Processor ):
2626 """
2727 A Prompt Toolkit input processor for Buffer that formats lines for display to the user.
2828 """
@@ -31,22 +31,16 @@ def __init__(self, prompt: '_MultiSelectPrompt'):
3131 self ._prompt = prompt
3232
3333 def apply_transformation (self , transformation_input : TransformationInput ) -> Transformation :
34- line_info = self ._prompt .lines [transformation_input .lineno ]
35- line_is_item = isinstance (line_info , self ._prompt .ItemLine )
34+ line_info = self ._prompt .item_lines [transformation_input .lineno ]
3635
3736 new_fragments : StyleAndTextTuples = []
3837
39- if line_is_item :
40- highlighted = transformation_input .lineno == self ._prompt .get_cursor_line ()
41- cursor = '▶' if highlighted else ' '
42- checkmark = 'x' if line_info .selected else ' '
43- fallback_style = (self ._prompt .highlighted_style
44- if highlighted else self ._prompt .neutral_style )
45- new_fragments .append ((fallback_style , f'{ cursor } [{ checkmark } ] ' ))
46- elif isinstance (line_info , self ._prompt .HeaderLine ):
47- fallback_style = self ._prompt .header_style
48- else :
49- fallback_style = self ._prompt .neutral_style
38+ highlighted = transformation_input .lineno == self ._prompt .get_cursor_line ()
39+ cursor = '▶' if highlighted else ' '
40+ checkmark = 'x' if line_info .selected else ' '
41+ fallback_style = (self ._prompt .highlighted_style
42+ if highlighted else self ._prompt .neutral_style )
43+ new_fragments .append ((fallback_style , f'{ cursor } [{ checkmark } ] ' ))
5044
5145 # Apply new style where adequate
5246 for fragment in transformation_input .fragments :
@@ -55,17 +49,35 @@ def apply_transformation(self, transformation_input: TransformationInput) -> Tra
5549 # NOTE: The idea is to respect search result markers (that have been inserted
5650 # by HighlightSearchProcessor or HighlightIncrementalSearchProcessor)
5751 # in item text, and only there.
58- if line_is_item :
59- new_style = old_style or fallback_style
60- else :
61- new_style = fallback_style
62-
52+ new_style = old_style or fallback_style
6353 new_fragments .append ((new_style , text ))
6454
6555 # Add right padding
66- if line_is_item :
67- padding_width = 2 + (self ._prompt .peak_item_label_length - len (line_info .text ))
68- new_fragments .append ((fallback_style , ' ' * padding_width ))
56+ padding_width = 2 + (self ._prompt .peak_item_label_length - len (line_info .text ))
57+ new_fragments .append ((fallback_style , ' ' * padding_width ))
58+
59+ return Transformation (fragments = new_fragments )
60+
61+
62+ class _NonItemRenderProcessor (Processor ):
63+ """
64+ A Prompt Toolkit input processor for Buffer that formats lines for display to the user.
65+ """
66+
67+ def __init__ (self , prompt : '_MultiSelectPrompt' , lines : List ):
68+ self ._prompt = prompt
69+ self ._lines = lines
70+
71+ def apply_transformation (self , transformation_input : TransformationInput ) -> Transformation :
72+ line_info = self ._lines [transformation_input .lineno ]
73+
74+ if isinstance (line_info , self ._prompt .HeaderLine ):
75+ new_style = self ._prompt .header_style
76+ else :
77+ new_style = self ._prompt .neutral_style
78+
79+ new_fragments : StyleAndTextTuples = [(new_style , text )
80+ for _old_style , text in transformation_input .fragments ]
6981
7082 return Transformation (fragments = new_fragments )
7183
@@ -121,8 +133,6 @@ class HeaderLine(_LineBase):
121133
122134 @dataclass
123135 class ItemLine (_LineBase ):
124- item_index : int
125- line_index : int
126136 value : Any
127137 selected : bool
128138
@@ -138,24 +148,32 @@ def __init__(self,
138148 self ._initial_cursor_item_index : int = initial_cursor_index
139149 self ._min_selection_count : int = min_selection_count
140150
141- self ._items : List [_MultiSelectPrompt ._ItemBase ] = []
142151 self .peak_item_label_length : int = 0
143- self .lines : [_MultiSelectPrompt .ItemLine ] = []
152+ self .item_lines : [_MultiSelectPrompt .ItemLine ] = []
153+ self ._header_lines : [_MultiSelectPrompt .HeaderLine ] = []
154+ self ._footer_lines : [_MultiSelectPrompt .PlainLine ] = []
144155
156+ self ._item_selection_window : Optional [Window ] = None
145157 self ._buffer : Optional [Buffer ] = None
146158 self ._document : Optional [Document ] = None
147159 self ._accepted_selection : List [Any ] = None
148160
149161 def _move_cursor_one_page_vertically (self , upwards : bool ):
150- page_height_in_lines = 10
151- current_line_index = self .get_cursor_line ()
152-
153- if upwards :
154- new_line_index = max (self ._items [0 ].line_index ,
155- current_line_index - page_height_in_lines )
162+ render_cursor_line = self ._item_selection_window .render_info .cursor_position .y
163+ page_height_in_lines = self ._item_selection_window .render_info .window_height
164+
165+ if upwards and render_cursor_line > 0 :
166+ new_line_index = self .get_cursor_line () - render_cursor_line
167+ elif not upwards and render_cursor_line < page_height_in_lines - 1 :
168+ new_line_index = self .get_cursor_line () + (page_height_in_lines - render_cursor_line
169+ - 1 )
156170 else :
157- new_line_index = min (self ._items [- 1 ].line_index ,
158- current_line_index + page_height_in_lines )
171+ current_line_index = self .get_cursor_line ()
172+ if upwards :
173+ new_line_index = max (0 , current_line_index - page_height_in_lines )
174+ else :
175+ new_line_index = min (
176+ len (self .item_lines ) - 1 , current_line_index + page_height_in_lines )
159177
160178 self ._move_cursor_to_line (new_line_index )
161179
@@ -168,25 +186,17 @@ def get_cursor_line(self):
168186 return row
169187
170188 def _move_cursor_one_step_vertically (self , upwards : bool ):
171- if len (self ._items ) < 2 :
189+ if len (self .item_lines ) < 2 :
172190 return
173191
174192 current_line_index = self .get_cursor_line ()
175193
176- if upwards :
177- if current_line_index == 0 :
178- return
179- candidates = range (current_line_index - 1 , 0 , - 1 )
180- else :
181- if current_line_index == len (self .lines ) - 1 :
182- return
183- candidates = range (current_line_index + 1 , len (self .lines ) - 1 , + 1 )
194+ # Can we even move any further in that direction?
195+ if ((upwards and current_line_index == 0 )
196+ or (not upwards and current_line_index == len (self .item_lines ) - 1 )):
197+ return
184198
185- for candidate_line_index in candidates :
186- line_info = self .lines [candidate_line_index ]
187- if isinstance (line_info , self .ItemLine ):
188- self ._move_cursor_to_line (candidate_line_index )
189- break
199+ self ._move_cursor_to_line (current_line_index + (- 1 if upwards else 1 ))
190200
191201 def _on_move_line_down (self , _event ):
192202 self ._move_cursor_one_step_vertically (upwards = False )
@@ -201,14 +211,14 @@ def _on_move_page_down(self, _event):
201211 self ._move_cursor_one_page_vertically (upwards = False )
202212
203213 def _on_move_to_first (self , _event ):
204- self ._move_cursor_to_line (self . _items [ 0 ]. line_index )
214+ self ._move_cursor_to_line (0 )
205215
206216 def _on_move_to_last (self , _event ):
207- self ._move_cursor_to_line (self ._items [ - 1 ]. line_index )
217+ self ._move_cursor_to_line (len ( self .item_lines ) - 1 )
208218
209219 def _on_toggle (self , _event ):
210220 line_index = self .get_cursor_line ()
211- line_info = self .lines [line_index ]
221+ line_info = self .item_lines [line_index ]
212222 line_info .selected = not line_info .selected
213223
214224 def _on_accept (self , event ):
@@ -222,25 +232,20 @@ def _on_abort(self, event):
222232 event .app .exit ()
223233
224234 def add_header (self , text ):
225- self .lines .append (self .HeaderLine (text ))
235+ self ._header_lines .append (self .HeaderLine (text ))
226236
227237 def add_item (self , value : Any , label : str = None , selected : bool = False ):
228238 if label is None :
229239 label = str (value )
230240
231241 self .peak_item_label_length = max (self .peak_item_label_length , len (label ))
232242
233- item_line = self .ItemLine (selected = selected ,
234- item_index = len (self ._items ),
235- line_index = len (self .lines ),
236- text = label ,
237- value = value )
243+ item_line = self .ItemLine (selected = selected , text = label , value = value )
238244
239- self .lines .append (item_line )
240- self ._items .append (item_line )
245+ self .item_lines .append (item_line )
241246
242- def add_text (self , text ):
243- self .lines .append (self .PlainLine (text ))
247+ def add_footer (self , text ):
248+ self ._footer_lines .append (self .PlainLine (text ))
244249
245250 def _create_key_bindings (self ):
246251 key_bindings = KeyBindings ()
@@ -265,81 +270,50 @@ def _create_key_bindings(self):
265270
266271 return key_bindings
267272
268- def _create_layout (self ):
273+ def _create_text_display_window_for (self , lines : List [_LineBase ]) -> Window :
274+ document = Document (text = '\n ' .join (line .text for line in lines ))
275+ buffer = Buffer (read_only = True , document = document )
276+ buffer_control = BufferControl (
277+ buffer = buffer , input_processors = [_NonItemRenderProcessor (prompt = self , lines = lines )])
278+ return Window (buffer_control ,
279+ wrap_lines = True ,
280+ height = Dimension (min = len (lines ), max = len (lines )))
281+
282+ def _create_layout (self ) -> Tuple [Layout , Window ]:
283+ header = self ._create_text_display_window_for (self ._header_lines )
284+ footer = self ._create_text_display_window_for (self ._footer_lines )
285+
269286 search = SearchToolbar (ignore_case = True )
270287 buffer_control = BufferControl (buffer = self ._buffer ,
271- input_processors = [_LineRenderProcessor (prompt = self )],
288+ input_processors = [_ItemRenderProcessor (prompt = self )],
272289 preview_search = True ,
273290 search_buffer_control = search .control )
274- hsplit = HSplit ([Window (buffer_control , always_hide_cursor = True , wrap_lines = True ), search ])
275- return Layout (hsplit )
291+ window_height = Dimension (min = 1 ,
292+ max = self ._document .line_count ,
293+ preferred = self ._document .line_count )
294+ item_selection_window = Window (buffer_control ,
295+ always_hide_cursor = True ,
296+ wrap_lines = True ,
297+ height = window_height )
298+ item_selection_window_plus_search = HSplit ([item_selection_window , search ])
299+ hsplit = HSplit ([header , item_selection_window_plus_search , footer ],
300+ padding = 1 ,
301+ align = VerticalAlign .TOP )
302+ return Layout (hsplit , focused_element = item_selection_window ), item_selection_window
276303
277304 def _collect_selected_values (self ):
278- return [item .value for item in self ._items if item .selected ]
279-
280- def _create_document_class (self , prompt : '_MultiSelectPrompt' ) -> Document :
281-
282- class ItemOnlySearchDocument (Document ):
283- """A Document that suppresses search results from non-item lines"""
284-
285- def _skip_non_item_matches (self , func : Callable , count : int ) -> Optional [int ]:
286- while True :
287- index = func (count = count )
288- if index is None :
289- return None
290-
291- effective_index = index + self .cursor_position
292- row , _col = self .translate_index_to_position (effective_index )
293- if isinstance (prompt .lines [row ], prompt .ItemLine ):
294- return index
295-
296- count += 1 # i.e. retry with next match
297-
298- # override
299- def find (self ,
300- sub : str ,
301- in_current_line : bool = False ,
302- include_current_position : bool = False ,
303- ignore_case : bool = False ,
304- count : int = 1 ) -> Optional [int ]:
305- func = partial (super ().find ,
306- sub = sub ,
307- in_current_line = in_current_line ,
308- include_current_position = include_current_position ,
309- ignore_case = ignore_case )
310- return self ._skip_non_item_matches (func , count )
311-
312- # override
313- def find_backwards (
314- self ,
315- sub : str ,
316- in_current_line : bool = False ,
317- ignore_case : bool = False ,
318- count : int = 1 ,
319- ) -> Optional [int ]:
320- func = partial (super ().find_backwards ,
321- sub = sub ,
322- in_current_line = in_current_line ,
323- ignore_case = ignore_case )
324- return self ._skip_non_item_matches (func , count )
325-
326- return ItemOnlySearchDocument
305+ return [item .value for item in self .item_lines if item .selected ]
327306
328307 def get_selected_values (self ) -> List [Any ]:
329- document_class = self ._create_document_class (prompt = self )
330- self ._document = document_class (text = '\n ' .join (line .text for line in self .lines ))
308+ self ._document = Document (text = '\n ' .join (line .text for line in self .item_lines ))
309+ self ._buffer = _LineJumpingBuffer (read_only = True , document = self ._document )
310+ layout , self ._item_selection_window = self ._create_layout ()
311+ app = Application (key_bindings = self ._create_key_bindings (), layout = layout )
331312
332- # Prompt Toolkit's Buffer is calling "Document(..)" internally,
333- # and this patch will make it use our CustomSearchDocument everywhere.
334- with patch ('prompt_toolkit.buffer.Document' , document_class ):
335- self ._buffer = _LineJumpingBuffer (read_only = True , document = self ._document )
336- app = Application (key_bindings = self ._create_key_bindings (),
337- layout = self ._create_layout ())
313+ self ._move_cursor_to_line (self ._initial_cursor_item_index )
314+ app .run ()
338315
339- self ._move_cursor_to_line (self ._items [self ._initial_cursor_item_index ].line_index )
340- app .run ()
341-
342- return self ._accepted_selection
316+ return self ._accepted_selection
343317
344318
345319def multiselect (messenger : Messenger , options : List [str ], initial_selection : List [int ], title : str ,
@@ -354,11 +328,11 @@ def multiselect(messenger: Messenger, options: List[str], initial_selection: Lis
354328 )
355329
356330 menu .add_header (title )
357- menu . add_text ( '' )
331+
358332 for i , option in enumerate (options ):
359333 menu .add_item (option , selected = i in initial_selection )
360- menu . add_text ( '' )
361- menu .add_text (help )
334+
335+ menu .add_footer (help )
362336
363337 messenger .produce_air ()
364338
0 commit comments