@@ -277,6 +277,21 @@ def _emoji_image(emoji: str, size: int = 18) -> ImageTk.PhotoImage:
277277 return photo
278278
279279
280+ def _fuzzy_match (query : str , text : str ) -> bool :
281+ """Return True if all chars of query appear in text in order (case-insensitive)."""
282+ if not query :
283+ return True
284+ q = query .lower ()
285+ t = text .lower ()
286+ i = 0
287+ for ch in t :
288+ if ch == q [i ]:
289+ i += 1
290+ if i == len (q ):
291+ return True
292+ return False
293+
294+
280295class StackWindow :
281296 def __init__ (self , root : tk .Tk , on_stack_change : Callable [[], None ]) -> None :
282297 self .root = root
@@ -290,6 +305,8 @@ def __init__(self, root: tk.Tk, on_stack_change: Callable[[], None]) -> None:
290305 self ._editing_index : int | None = None
291306 self ._drag_start : int | None = None
292307 self ._drag_y0 : int = 0
308+ self ._filter_text : str = ""
309+ self ._visible_indices : list [int ] = []
293310
294311 root .title ("Task Stack" )
295312 root .resizable (True , True )
@@ -372,6 +389,7 @@ def _build_ui(self) -> None:
372389 self ._entry .bind ("<Home>" , self ._on_entry_home )
373390 self ._entry .bind ("<End>" , self ._on_entry_end )
374391 self ._entry .bind ("<Escape>" , self ._on_entry_escape )
392+ self ._entry .bind ("<KeyRelease>" , self ._on_entry_key_release )
375393
376394 self ._canvas = tk .Canvas (
377395 self .root , bg = t .bg , highlightthickness = 0 , width = 460 , height = _ROW_HEIGHT
@@ -569,6 +587,8 @@ def refresh(self, tasks: list[st.Task] | None = None) -> None:
569587 self ._cursor = None
570588 self ._desc_shown_for = None
571589 self ._cancel_edit ()
590+ self ._filter_text = ""
591+ self ._entry .delete (0 , tk .END )
572592 self ._redraw ()
573593
574594 # ------------------------------------------------------------------
@@ -591,8 +611,17 @@ def _redraw(self) -> None:
591611 ts_x = lc_x - self ._lc_col_w - gap
592612 text_right = ts_x - self ._ts_col_w - gap
593613
594- for i , task in enumerate (self ._tasks ):
595- y0 = i * _ROW_HEIGHT
614+ if self ._filter_text :
615+ self ._visible_indices = [
616+ idx for idx , task in enumerate (self ._tasks )
617+ if _fuzzy_match (self ._filter_text , task .text )
618+ ]
619+ else :
620+ self ._visible_indices = list (range (len (self ._tasks )))
621+
622+ for row , i in enumerate (self ._visible_indices ):
623+ task = self ._tasks [i ]
624+ y0 = row * _ROW_HEIGHT
596625 y1 = y0 + _ROW_HEIGHT
597626
598627 bg = t .selected_bg if i in self ._selected else t .bg
@@ -670,6 +699,14 @@ def _redraw(self) -> None:
670699 fill = t .fg_muted ,
671700 font = self ._font_normal ,
672701 )
702+ elif not self ._visible_indices :
703+ c .create_text (
704+ width // 2 ,
705+ _ROW_HEIGHT // 2 ,
706+ text = "No matching tasks" ,
707+ fill = t .fg_muted ,
708+ font = self ._font_normal ,
709+ )
673710
674711 current = frozenset (self ._selected )
675712 if current != self ._last_selected :
@@ -680,8 +717,10 @@ def _redraw(self) -> None:
680717 self ._entry .delete (0 , tk .END )
681718 self ._entry .insert (0 , self ._tasks [sole ].text )
682719 self ._entry .select_range (0 , tk .END )
720+ self ._filter_text = ""
683721 elif self ._editing_index is None :
684722 self ._entry .delete (0 , tk .END )
723+ self ._filter_text = ""
685724
686725 if len (self ._selected ) == 1 :
687726 (sole ,) = self ._selected
@@ -721,6 +760,7 @@ def _submit_entry(self, *, position: _InsertPosition = _InsertPosition.FIRST) ->
721760 if not text :
722761 return
723762 self ._entry .delete (0 , tk .END )
763+ self ._filter_text = ""
724764 if position == _InsertPosition .NEXT :
725765 tasks = st .push_next (text )
726766 elif position == _InsertPosition .LAST :
@@ -824,24 +864,54 @@ def _on_entry_end(self, _event: tk.Event) -> str:
824864 return "break"
825865
826866 def _on_canvas_escape (self , _event : tk .Event ) -> str :
827- if self ._selected :
867+ if self ._selected or self . _filter_text :
828868 self ._selected = set ()
829869 self ._anchor = None
830870 self ._cursor = None
831871 if self ._editing_index is None :
832872 self ._entry .delete (0 , tk .END )
873+ self ._filter_text = ""
833874 self ._redraw ()
834875 return "break"
835876 self .hide ()
836877 return "break"
837878
838879 def _on_entry_escape (self , _event : tk .Event ) -> str :
839- if self ._editing_index is not None :
880+ if self ._entry . get () :
840881 self ._cancel_edit ()
882+ self ._entry .delete (0 , tk .END )
883+ self ._filter_text = ""
884+ self ._selected = set ()
885+ self ._anchor = None
886+ self ._cursor = None
887+ self ._last_selected = frozenset ()
841888 self ._redraw ()
889+ return "break"
842890 self ._canvas .focus_set ()
843891 return "break"
844892
893+ _FILTER_IGNORED_KEYSYMS = frozenset ({
894+ "Return" , "KP_Enter" , "Escape" , "Up" , "Down" , "Left" , "Right" ,
895+ "Home" , "End" , "Prior" , "Next" , "Tab" , "ISO_Left_Tab" ,
896+ "Shift_L" , "Shift_R" , "Control_L" , "Control_R" ,
897+ "Alt_L" , "Alt_R" , "Meta_L" , "Meta_R" , "Super_L" , "Super_R" ,
898+ "Caps_Lock" , "Num_Lock" , "Scroll_Lock" ,
899+ })
900+
901+ def _on_entry_key_release (self , event : tk .Event ) -> None :
902+ if event .keysym in self ._FILTER_IGNORED_KEYSYMS :
903+ return
904+ new_text = self ._entry .get ()
905+ if new_text == self ._filter_text and self ._editing_index is None :
906+ return
907+ self ._filter_text = new_text
908+ if self ._editing_index is None and self ._selected :
909+ self ._selected = set ()
910+ self ._anchor = None
911+ self ._cursor = None
912+ self ._last_selected = frozenset ()
913+ self ._redraw ()
914+
845915 # When Shift is held, numpad navigation keys report as KP_Up/KP_Down/etc.
846916 # instead of KP_8/KP_2/etc. Map them back to the digit they represent.
847917 _SHIFT_KP_DIGIT : dict [str , str ] = {
@@ -888,18 +958,20 @@ def _on_key(self, event: tk.Event) -> None:
888958 # Treat unambiguous Win-shifted-numpad hits as Shift (the bit was stripped by OS).
889959 _shift = _shift_bit or _win_kp is not None
890960 if digit is not None :
891- if digit < len (self ._tasks ):
961+ visible = self ._visible_indices or list (range (len (self ._tasks )))
962+ if digit < len (visible ):
963+ real_idx = visible [digit ]
892964 self ._save_desc ()
893965 if _shift : # Shift held: extend from anchor
894966 if self ._anchor is None :
895- self ._anchor = digit
896- self ._cursor = digit
967+ self ._anchor = real_idx
968+ self ._cursor = real_idx
897969 lo , hi = min (self ._anchor , self ._cursor ), max (self ._anchor , self ._cursor )
898970 self ._selected = set (range (lo , hi + 1 ))
899971 else :
900- self ._anchor = digit
901- self ._cursor = digit
902- self ._selected = {digit }
972+ self ._anchor = real_idx
973+ self ._cursor = real_idx
974+ self ._selected = {real_idx }
903975 self ._canvas .focus_set ()
904976 self ._redraw ()
905977 return
@@ -914,28 +986,39 @@ def _on_key(self, event: tk.Event) -> None:
914986 self ._entry .delete (0 , tk .END )
915987 self ._entry .focus_set ()
916988 self ._entry .insert (tk .END , event .char )
989+ self ._filter_text = self ._entry .get ()
990+ if self ._selected :
991+ self ._selected = set ()
992+ self ._anchor = None
993+ self ._cursor = None
994+ self ._last_selected = frozenset ()
995+ self ._redraw ()
917996 return
918997
919998 if event .keysym in ("Up" , "Down" ):
920999 self ._save_desc ()
9211000 delta = - 1 if event .keysym == "Up" else 1
1001+ visible = self ._visible_indices or list (range (len (self ._tasks )))
1002+ if not visible :
1003+ return
9221004 if event .state & 0x0001 : # Shift: move cursor, keep anchor fixed
9231005 if self ._anchor is None :
924- # Nothing selected yet — pick an edge to anchor on
925- self ._anchor = 0 if event .keysym == "Down" else len (self ._tasks ) - 1
1006+ self ._anchor = visible [0 ] if event .keysym == "Down" else visible [- 1 ]
9261007 self ._cursor = self ._anchor
9271008 cursor_pos = self ._cursor if self ._cursor is not None else self ._anchor
928- new_cursor = max (0 , min (len (self ._tasks ) - 1 , cursor_pos + delta ))
929- self ._cursor = new_cursor
1009+ cur_v = visible .index (cursor_pos ) if cursor_pos in visible else 0
1010+ new_v = max (0 , min (len (visible ) - 1 , cur_v + delta ))
1011+ self ._cursor = visible [new_v ]
9301012 lo , hi = min (self ._anchor , self ._cursor ), max (self ._anchor , self ._cursor )
9311013 self ._selected = set (range (lo , hi + 1 ))
9321014 else :
933- # Plain Up/Down: move single selection
9341015 if not self ._selected :
935- pos = 0 if event .keysym == "Down" else len ( self . _tasks ) - 1
1016+ pos = visible [ 0 ] if event .keysym == "Down" else visible [ - 1 ]
9361017 else :
9371018 cur = self ._cursor if self ._cursor is not None else min (self ._selected )
938- pos = max (0 , min (len (self ._tasks ) - 1 , cur + delta ))
1019+ cur_v = visible .index (cur ) if cur in visible else 0
1020+ new_v = max (0 , min (len (visible ) - 1 , cur_v + delta ))
1021+ pos = visible [new_v ]
9391022 self ._anchor = pos
9401023 self ._cursor = pos
9411024 self ._selected = {pos }
@@ -1097,7 +1180,10 @@ def _close() -> None:
10971180 # ------------------------------------------------------------------
10981181
10991182 def _row_at (self , y : int ) -> int :
1100- return max (0 , min (len (self ._tasks ) - 1 , y // _ROW_HEIGHT ))
1183+ if not self ._visible_indices :
1184+ return max (0 , min (len (self ._tasks ) - 1 , y // _ROW_HEIGHT ))
1185+ row = max (0 , min (len (self ._visible_indices ) - 1 , y // _ROW_HEIGHT ))
1186+ return self ._visible_indices [row ]
11011187
11021188 def _drag_press (self , event : tk .Event ) -> None :
11031189 if not self ._tasks :
0 commit comments