Skip to content

Commit b53445f

Browse files
committed
feat: implement fuzzy search functionality in StackWindow
- Added a new method `_fuzzy_match` to enable case-insensitive matching of task names based on user input. - Integrated filtering logic to display only tasks that match the search query, enhancing user experience. - Updated the entry management to clear the filter and selections appropriately when necessary. - Improved visual feedback for no matching tasks in the UI.
1 parent 1329d91 commit b53445f

1 file changed

Lines changed: 104 additions & 18 deletions

File tree

python/src/task_stack/window.py

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
280295
class 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

Comments
 (0)