@@ -18,6 +18,9 @@ class TreeFilterMixin:
1818
1919 _tree_filter_visible : bool = False
2020 _tree_filter_text : str = ""
21+ _tree_filter_query : str = ""
22+ _tree_filter_fuzzy : bool = False
23+ _tree_filter_typing : bool = False
2124 _tree_filter_matches : list [Any ] = []
2225 _tree_filter_match_index : int = 0
2326 _tree_original_labels : dict [int , str ] = {}
@@ -29,6 +32,9 @@ def action_tree_filter(self: AppProtocol) -> None:
2932
3033 self ._tree_filter_visible = True
3134 self ._tree_filter_text = ""
35+ self ._tree_filter_query = ""
36+ self ._tree_filter_fuzzy = False
37+ self ._tree_filter_typing = True
3238 self ._tree_filter_matches = []
3339 self ._tree_filter_match_index = 0
3440 self ._tree_original_labels = {}
@@ -41,14 +47,19 @@ def action_tree_filter_close(self: AppProtocol) -> None:
4147 """Close the tree filter and restore tree."""
4248 self ._tree_filter_visible = False
4349 self ._tree_filter_text = ""
50+ self ._tree_filter_query = ""
51+ self ._tree_filter_fuzzy = False
52+ self ._tree_filter_typing = False
4453 self .tree_filter_input .hide ()
4554 self ._restore_tree_labels ()
4655 self ._show_all_tree_nodes ()
4756 self ._update_footer_bindings ()
4857
4958 def action_tree_filter_accept (self : AppProtocol ) -> None :
50- """Accept current filter selection and close."""
51- self .action_tree_filter_close ()
59+ """Accept current filter selection and switch to navigation mode."""
60+ self ._tree_filter_typing = False
61+ self .tree_filter_input .hide ()
62+ self ._update_footer_bindings ()
5263
5364 def action_tree_filter_next (self : AppProtocol ) -> None :
5465 """Move to next filter match."""
@@ -97,15 +108,40 @@ def on_key(self: AppProtocol, event: Any) -> None:
97108 return
98109
99110 key = event .key
111+ if key == "enter" :
112+ self .action_tree_filter_accept ()
113+ event .prevent_default ()
114+ event .stop ()
115+ return
116+
117+ if not self ._tree_filter_typing :
118+ if key in ("n" , "j" ):
119+ self .action_tree_filter_next ()
120+ event .prevent_default ()
121+ event .stop ()
122+ return
123+
124+ if key in ("N" , "k" ):
125+ self .action_tree_filter_prev ()
126+ event .prevent_default ()
127+ event .stop ()
128+ return
129+
130+ if key == "/" :
131+ self .action_tree_filter ()
132+ event .prevent_default ()
133+ event .stop ()
134+ return
100135
101136 # Handle backspace
102137 if key == "backspace" :
103- if self ._tree_filter_text :
104- self ._tree_filter_text = self ._tree_filter_text [:- 1 ]
105- self ._update_tree_filter ()
106- else :
107- # Exit filter when backspacing with no text
108- self .action_tree_filter_close ()
138+ if self ._tree_filter_typing :
139+ if self ._tree_filter_text :
140+ self ._tree_filter_text = self ._tree_filter_text [:- 1 ]
141+ self ._update_tree_filter ()
142+ else :
143+ # Exit filter when backspacing with no text
144+ self .action_tree_filter_close ()
109145 event .prevent_default ()
110146 event .stop ()
111147 return
@@ -114,6 +150,14 @@ def on_key(self: AppProtocol, event: Any) -> None:
114150 # event.key might be "shift+?" but event.character will be "?"
115151 char = getattr (event , "character" , None )
116152 if char and char .isprintable ():
153+ if char == "/" and not self ._tree_filter_typing :
154+ self .action_tree_filter ()
155+ event .prevent_default ()
156+ event .stop ()
157+ return
158+ if not self ._tree_filter_typing :
159+ super ().on_key (event ) # type: ignore[misc]
160+ return
117161 self ._tree_filter_text += char
118162 self ._update_tree_filter ()
119163 event .prevent_default ()
@@ -127,8 +171,11 @@ def _update_tree_filter(self: AppProtocol) -> None:
127171 """Update the tree based on current filter text."""
128172 self ._restore_tree_labels ()
129173 total = self ._count_all_nodes ()
174+ raw_text = self ._tree_filter_text
175+ self ._tree_filter_fuzzy = raw_text .startswith ("~" )
176+ self ._tree_filter_query = raw_text [1 :] if self ._tree_filter_fuzzy else raw_text
130177
131- if not self ._tree_filter_text :
178+ if not self ._tree_filter_query :
132179 self ._show_all_tree_nodes ()
133180 self ._tree_filter_matches = []
134181 self .tree_filter_input .set_filter ("" , 0 , total )
@@ -171,7 +218,15 @@ def _find_matching_nodes(
171218 # Get node label text for matching
172219 label_text = self ._get_node_label_text (node )
173220 if label_text :
174- matched , indices = fuzzy_match (self ._tree_filter_text , label_text )
221+ if self ._tree_filter_fuzzy :
222+ matched , indices = fuzzy_match (self ._tree_filter_query , label_text )
223+ else :
224+ label_lower = label_text .lower ()
225+ query_lower = self ._tree_filter_query .lower ()
226+ start = label_lower .find (query_lower )
227+ matched = start >= 0
228+ indices = list (range (start , start + len (self ._tree_filter_query ))) if matched else []
229+
175230 if matched :
176231 node_matches = True
177232 matches .append (node )
@@ -231,12 +286,12 @@ def _set_node_visibility(
231286 child_id = id (child )
232287 is_match = child_id in match_ids
233288 is_ancestor = child_id in ancestor_ids
234- should_show = is_match or is_ancestor or not self ._tree_filter_text
289+ should_show = is_match or is_ancestor or not self ._tree_filter_query
235290
236291 # Use display style to hide/show
237292 # Note: Textual Tree doesn't have per-node visibility,
238293 # so we'll dim non-matching nodes instead
239- if not should_show and self ._tree_filter_text :
294+ if not should_show and self ._tree_filter_query :
240295 # Dim non-matching nodes
241296 original = self ._tree_original_labels .get (child_id , str (child .label ))
242297 if child_id not in self ._tree_original_labels :
0 commit comments