23
23
from prompt_toolkit .utils import get_cwidth
24
24
25
25
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
+ ):
27
30
"""Presents a list of options and allows the user to select one.
28
31
29
32
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):
42
45
:type max_height: int
43
46
:param max_height: The max number of items to show in the list at a time.
44
47
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
+
45
54
:returns: The selected element from the items list.
46
55
"""
47
56
app_bindings = KeyBindings ()
@@ -51,8 +60,20 @@ def exit_app(event):
51
60
event .app .exit (exception = KeyboardInterrupt , style = 'class:aborting' )
52
61
53
62
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
+
54
75
menu_window = Window (
55
- SelectionMenuControl ( items , display_format = display_format ) ,
76
+ menu_control ,
56
77
always_hide_cursor = False ,
57
78
height = Dimension (min = min_height , max = min_height ),
58
79
scroll_offsets = ScrollOffsets (),
@@ -122,6 +143,8 @@ def is_focusable(self):
122
143
123
144
def preferred_width (self , max_width ):
124
145
items = self ._get_items ()
146
+ if not items :
147
+ return self .MIN_WIDTH
125
148
if self ._display_format :
126
149
items = (self ._display_format (i ) for i in items )
127
150
max_item_width = max (get_cwidth (i ) for i in items )
@@ -188,6 +211,157 @@ def app_result(event):
188
211
return kb
189
212
190
213
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
+
191
365
class CollapsableSelectionMenuControl (SelectionMenuControl ):
192
366
"""Menu that collapses to text with selection when loses focus"""
193
367
0 commit comments