2323from prompt_toolkit .utils import get_cwidth
2424
2525
26- def select_menu (items , display_format = None , max_height = 10 ):
26+ def select_menu (
27+ items , display_format = None , max_height = 10 , enable_filter = False ,
28+ no_results_message = None
29+ ):
2730 """Presents a list of options and allows the user to select one.
2831
2932 This presents a static list of options and prompts the user to select one.
@@ -42,6 +45,12 @@ def select_menu(items, display_format=None, max_height=10):
4245 :type max_height: int
4346 :param max_height: The max number of items to show in the list at a time.
4447
48+ :type enable_filter: bool
49+ :param enable_filter: Enable keyboard filtering of items.
50+
51+ :type no_results_message: str
52+ :param no_results_message: Message to show when filtering returns no results.
53+
4554 :returns: The selected element from the items list.
4655 """
4756 app_bindings = KeyBindings ()
@@ -51,8 +60,20 @@ def exit_app(event):
5160 event .app .exit (exception = KeyboardInterrupt , style = 'class:aborting' )
5261
5362 min_height = min (max_height , len (items ))
63+ if enable_filter :
64+ # Add 1 to height for filter line
65+ min_height = min (max_height + 1 , len (items ) + 1 )
66+ menu_control = FilterableSelectionMenuControl (
67+ items , display_format = display_format ,
68+ no_results_message = no_results_message
69+ )
70+ else :
71+ menu_control = SelectionMenuControl (
72+ items , display_format = display_format
73+ )
74+
5475 menu_window = Window (
55- SelectionMenuControl ( items , display_format = display_format ) ,
76+ menu_control ,
5677 always_hide_cursor = False ,
5778 height = Dimension (min = min_height , max = min_height ),
5879 scroll_offsets = ScrollOffsets (),
@@ -122,6 +143,8 @@ def is_focusable(self):
122143
123144 def preferred_width (self , max_width ):
124145 items = self ._get_items ()
146+ if not items :
147+ return self .MIN_WIDTH
125148 if self ._display_format :
126149 items = (self ._display_format (i ) for i in items )
127150 max_item_width = max (get_cwidth (i ) for i in items )
@@ -188,6 +211,157 @@ def app_result(event):
188211 return kb
189212
190213
214+ class FilterableSelectionMenuControl (SelectionMenuControl ):
215+ """Menu that supports keyboard filtering of items"""
216+
217+ def __init__ (self , items , display_format = None , cursor = '>' , no_results_message = None ):
218+ super ().__init__ (items , display_format = display_format , cursor = cursor )
219+ self ._filter_text = ''
220+ self ._filtered_items = items if items else []
221+ self ._all_items = items if items else []
222+ self ._filter_enabled = True
223+ self ._no_results_message = no_results_message or 'No matching items found'
224+
225+ def _get_items (self ):
226+ if callable (self ._all_items ):
227+ self ._all_items = self ._all_items ()
228+ return self ._filtered_items
229+
230+ def preferred_width (self , max_width ):
231+ # Ensure minimum width for search display
232+ min_search_width = max (20 , len ("Search: " + self ._filter_text ) + 5 )
233+
234+ # Get width from filtered items
235+ items = self ._filtered_items
236+ if not items :
237+ # Width for no results message
238+ no_results_width = get_cwidth (self ._no_results_message ) + 4
239+ return max (no_results_width , min_search_width )
240+
241+ if self ._display_format :
242+ items_display = [self ._display_format (i ) for i in items ]
243+ else :
244+ items_display = [str (i ) for i in items ]
245+
246+ if items_display :
247+ max_item_width = max (get_cwidth (i ) for i in items_display )
248+ max_item_width += self ._format_overhead
249+ else :
250+ max_item_width = self .MIN_WIDTH
251+
252+ max_item_width = max (max_item_width , min_search_width )
253+
254+ if max_item_width < self .MIN_WIDTH :
255+ max_item_width = self .MIN_WIDTH
256+ return min (max_width , max_item_width )
257+
258+ def _update_filtered_items (self ):
259+ """Update the filtered items based on the current filter text"""
260+ if not self ._filter_text :
261+ self ._filtered_items = self ._all_items
262+ else :
263+ filter_lower = self ._filter_text .lower ()
264+ self ._filtered_items = [
265+ item
266+ for item in self ._all_items
267+ if filter_lower
268+ in (
269+ self ._display_format (item )
270+ if self ._display_format
271+ else str (item )
272+ ).lower ()
273+ ]
274+
275+ # Reset selection if it's out of bounds
276+ if self ._selection >= len (self ._filtered_items ):
277+ self ._selection = 0
278+
279+ def preferred_height (self , width , max_height , wrap_lines , get_line_prefix ):
280+ # Add 1 extra line for the filter display
281+ return min (max_height , len (self ._get_items ()) + 1 )
282+
283+ def create_content (self , width , height ):
284+ def get_line (i ):
285+ # First line shows the filter
286+ if i == 0 :
287+ filter_display = (
288+ f"Search: { self ._filter_text } _"
289+ if self ._filter_enabled
290+ else f"Search: { self ._filter_text } "
291+ )
292+ return [('class:filter' , filter_display )]
293+
294+ # Show "No results" message if filtered items is empty
295+ if not self ._filtered_items :
296+ if i == 1 :
297+ return [
298+ ('class:no-results' , f' { self ._no_results_message } ' )
299+ ]
300+ return [('' , '' )]
301+
302+ # Adjust for the filter line
303+ item_index = i - 1
304+ if item_index >= len (self ._filtered_items ):
305+ return [('' , '' )]
306+
307+ item = self ._filtered_items [item_index ]
308+ is_selected = item_index == self ._selection
309+ return self ._menu_item_fragment (item , is_selected , width )
310+
311+ # Ensure at least 2 lines (search + no results or items)
312+ line_count = max (2 , len (self ._filtered_items ) + 1 )
313+ cursor_y = self ._selection + 1 if self ._filtered_items else 0
314+
315+ return UIContent (
316+ get_line = get_line ,
317+ cursor_position = Point (x = 0 , y = cursor_y ),
318+ line_count = line_count ,
319+ )
320+
321+ def get_key_bindings (self ):
322+ kb = KeyBindings ()
323+
324+ @kb .add ('up' )
325+ def move_up (event ):
326+ if len (self ._filtered_items ) > 0 :
327+ self ._move_cursor (- 1 )
328+
329+ @kb .add ('down' )
330+ def move_down (event ):
331+ if len (self ._filtered_items ) > 0 :
332+ self ._move_cursor (1 )
333+
334+ @kb .add ('enter' )
335+ def app_result (event ):
336+ if len (self ._filtered_items ) > 0 :
337+ result = self ._filtered_items [self ._selection ]
338+ event .app .exit (result = result )
339+
340+ @kb .add ('backspace' )
341+ def delete_char (event ):
342+ if self ._filter_text :
343+ self ._filter_text = self ._filter_text [:- 1 ]
344+ self ._update_filtered_items ()
345+
346+ @kb .add ('c-u' )
347+ def clear_filter (event ):
348+ self ._filter_text = ''
349+ self ._update_filtered_items ()
350+
351+ # Add support for typing any character
352+ from string import printable
353+
354+ for char in printable :
355+ if char not in ('\n ' , '\r ' , '\t ' ):
356+
357+ @kb .add (char )
358+ def add_char (event , c = char ):
359+ self ._filter_text += c
360+ self ._update_filtered_items ()
361+
362+ return kb
363+
364+
191365class CollapsableSelectionMenuControl (SelectionMenuControl ):
192366 """Menu that collapses to text with selection when loses focus"""
193367
0 commit comments