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+ ):
2729 """Presents a list of options and allows the user to select one.
2830
2931 This presents a static list of options and prompts the user to select one.
@@ -42,6 +44,9 @@ def select_menu(items, display_format=None, max_height=10):
4244 :type max_height: int
4345 :param max_height: The max number of items to show in the list at a time.
4446
47+ :type enable_filter: bool
48+ :param enable_filter: Enable keyboard filtering of items.
49+
4550 :returns: The selected element from the items list.
4651 """
4752 app_bindings = KeyBindings ()
@@ -51,8 +56,19 @@ def exit_app(event):
5156 event .app .exit (exception = KeyboardInterrupt , style = 'class:aborting' )
5257
5358 min_height = min (max_height , len (items ))
59+ if enable_filter :
60+ # Add 1 to height for filter line
61+ min_height = min (max_height + 1 , len (items ) + 1 )
62+ menu_control = FilterableSelectionMenuControl (
63+ items , display_format = display_format
64+ )
65+ else :
66+ menu_control = SelectionMenuControl (
67+ items , display_format = display_format
68+ )
69+
5470 menu_window = Window (
55- SelectionMenuControl ( items , display_format = display_format ) ,
71+ menu_control ,
5672 always_hide_cursor = False ,
5773 height = Dimension (min = min_height , max = min_height ),
5874 scroll_offsets = ScrollOffsets (),
@@ -122,6 +138,8 @@ def is_focusable(self):
122138
123139 def preferred_width (self , max_width ):
124140 items = self ._get_items ()
141+ if not items :
142+ return self .MIN_WIDTH
125143 if self ._display_format :
126144 items = (self ._display_format (i ) for i in items )
127145 max_item_width = max (get_cwidth (i ) for i in items )
@@ -188,6 +206,157 @@ def app_result(event):
188206 return kb
189207
190208
209+ class FilterableSelectionMenuControl (SelectionMenuControl ):
210+ """Menu that supports keyboard filtering of items"""
211+
212+ def __init__ (self , items , display_format = None , cursor = '>' , no_results_message = None ):
213+ super ().__init__ (items , display_format = display_format , cursor = cursor )
214+ self ._filter_text = ''
215+ self ._filtered_items = items if items else []
216+ self ._all_items = items if items else []
217+ self ._filter_enabled = True
218+ self ._no_results_message = no_results_message or 'No matching items found'
219+
220+ def _get_items (self ):
221+ if callable (self ._all_items ):
222+ self ._all_items = self ._all_items ()
223+ return self ._filtered_items
224+
225+ def preferred_width (self , max_width ):
226+ # Ensure minimum width for search display
227+ min_search_width = max (20 , len ("Search: " + self ._filter_text ) + 5 )
228+
229+ # Get width from filtered items
230+ items = self ._filtered_items
231+ if not items :
232+ # Width for no results message
233+ no_results_width = get_cwidth (self ._no_results_message ) + 4
234+ return max (no_results_width , min_search_width )
235+
236+ if self ._display_format :
237+ items_display = [self ._display_format (i ) for i in items ]
238+ else :
239+ items_display = [str (i ) for i in items ]
240+
241+ if items_display :
242+ max_item_width = max (get_cwidth (i ) for i in items_display )
243+ max_item_width += self ._format_overhead
244+ else :
245+ max_item_width = self .MIN_WIDTH
246+
247+ max_item_width = max (max_item_width , min_search_width )
248+
249+ if max_item_width < self .MIN_WIDTH :
250+ max_item_width = self .MIN_WIDTH
251+ return min (max_width , max_item_width )
252+
253+ def _update_filtered_items (self ):
254+ """Update the filtered items based on the current filter text"""
255+ if not self ._filter_text :
256+ self ._filtered_items = self ._all_items
257+ else :
258+ filter_lower = self ._filter_text .lower ()
259+ self ._filtered_items = [
260+ item
261+ for item in self ._all_items
262+ if filter_lower
263+ in (
264+ self ._display_format (item )
265+ if self ._display_format
266+ else str (item )
267+ ).lower ()
268+ ]
269+
270+ # Reset selection if it's out of bounds
271+ if self ._selection >= len (self ._filtered_items ):
272+ self ._selection = 0
273+
274+ def preferred_height (self , width , max_height , wrap_lines , get_line_prefix ):
275+ # Add 1 extra line for the filter display
276+ return min (max_height , len (self ._get_items ()) + 1 )
277+
278+ def create_content (self , width , height ):
279+ def get_line (i ):
280+ # First line shows the filter
281+ if i == 0 :
282+ filter_display = (
283+ f"Search: { self ._filter_text } _"
284+ if self ._filter_enabled
285+ else f"Search: { self ._filter_text } "
286+ )
287+ return [('class:filter' , filter_display )]
288+
289+ # Show "No results" message if filtered items is empty
290+ if not self ._filtered_items :
291+ if i == 1 :
292+ return [
293+ ('class:no-results' , f' { self ._no_results_message } ' )
294+ ]
295+ return [('' , '' )]
296+
297+ # Adjust for the filter line
298+ item_index = i - 1
299+ if item_index >= len (self ._filtered_items ):
300+ return [('' , '' )]
301+
302+ item = self ._filtered_items [item_index ]
303+ is_selected = item_index == self ._selection
304+ return self ._menu_item_fragment (item , is_selected , width )
305+
306+ # Ensure at least 2 lines (search + no results or items)
307+ line_count = max (2 , len (self ._filtered_items ) + 1 )
308+ cursor_y = self ._selection + 1 if self ._filtered_items else 0
309+
310+ return UIContent (
311+ get_line = get_line ,
312+ cursor_position = Point (x = 0 , y = cursor_y ),
313+ line_count = line_count ,
314+ )
315+
316+ def get_key_bindings (self ):
317+ kb = KeyBindings ()
318+
319+ @kb .add ('up' )
320+ def move_up (event ):
321+ if len (self ._filtered_items ) > 0 :
322+ self ._move_cursor (- 1 )
323+
324+ @kb .add ('down' )
325+ def move_down (event ):
326+ if len (self ._filtered_items ) > 0 :
327+ self ._move_cursor (1 )
328+
329+ @kb .add ('enter' )
330+ def app_result (event ):
331+ if len (self ._filtered_items ) > 0 :
332+ result = self ._filtered_items [self ._selection ]
333+ event .app .exit (result = result )
334+
335+ @kb .add ('backspace' )
336+ def delete_char (event ):
337+ if self ._filter_text :
338+ self ._filter_text = self ._filter_text [:- 1 ]
339+ self ._update_filtered_items ()
340+
341+ @kb .add ('c-u' )
342+ def clear_filter (event ):
343+ self ._filter_text = ''
344+ self ._update_filtered_items ()
345+
346+ # Add support for typing any character
347+ from string import printable
348+
349+ for char in printable :
350+ if char not in ('\n ' , '\r ' , '\t ' ):
351+
352+ @kb .add (char )
353+ def add_char (event , c = char ):
354+ self ._filter_text += c
355+ self ._update_filtered_items ()
356+
357+ return kb
358+
359+
191360class CollapsableSelectionMenuControl (SelectionMenuControl ):
192361 """Menu that collapses to text with selection when loses focus"""
193362
0 commit comments