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
+ ):
27
29
"""Presents a list of options and allows the user to select one.
28
30
29
31
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):
42
44
:type max_height: int
43
45
:param max_height: The max number of items to show in the list at a time.
44
46
47
+ :type enable_filter: bool
48
+ :param enable_filter: Enable keyboard filtering of items.
49
+
45
50
:returns: The selected element from the items list.
46
51
"""
47
52
app_bindings = KeyBindings ()
@@ -51,8 +56,19 @@ def exit_app(event):
51
56
event .app .exit (exception = KeyboardInterrupt , style = 'class:aborting' )
52
57
53
58
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
+
54
70
menu_window = Window (
55
- SelectionMenuControl ( items , display_format = display_format ) ,
71
+ menu_control ,
56
72
always_hide_cursor = False ,
57
73
height = Dimension (min = min_height , max = min_height ),
58
74
scroll_offsets = ScrollOffsets (),
@@ -122,6 +138,8 @@ def is_focusable(self):
122
138
123
139
def preferred_width (self , max_width ):
124
140
items = self ._get_items ()
141
+ if not items :
142
+ return self .MIN_WIDTH
125
143
if self ._display_format :
126
144
items = (self ._display_format (i ) for i in items )
127
145
max_item_width = max (get_cwidth (i ) for i in items )
@@ -188,6 +206,157 @@ def app_result(event):
188
206
return kb
189
207
190
208
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
+
191
360
class CollapsableSelectionMenuControl (SelectionMenuControl ):
192
361
"""Menu that collapses to text with selection when loses focus"""
193
362
0 commit comments