From 505fdbcd3d1c4acefc1c6879b6c0b4f483195818 Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 19 Dec 2025 16:30:45 -0500 Subject: [PATCH] Add search feature to tasks app with / keybinding and live filtering --- src/tasks/app.py | 119 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/src/tasks/app.py b/src/tasks/app.py index ba50c38..aa655fc 100644 --- a/src/tasks/app.py +++ b/src/tasks/app.py @@ -12,7 +12,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import ScrollableContainer, Vertical, Horizontal from textual.logging import TextualHandler -from textual.widgets import DataTable, Footer, Header, Static, Markdown +from textual.widgets import DataTable, Footer, Header, Static, Markdown, Input from .config import get_config, TasksAppConfig from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project @@ -154,6 +154,35 @@ class TasksApp(App): #notes-pane.hidden { display: none; } + + #search-container { + dock: top; + height: 3; + width: 100%; + background: $surface; + border-bottom: solid $primary; + padding: 0 1; + } + + #search-container.hidden { + display: none; + } + + #search-container > Horizontal { + height: 100%; + width: 100%; + align: left middle; + } + + #search-container .search-label { + width: auto; + padding: 0 1; + color: $primary; + } + + #search-input { + width: 1fr; + } """ BINDINGS = [ @@ -175,6 +204,8 @@ class TasksApp(App): Binding("r", "refresh", "Refresh", show=True), Binding("y", "sync", "Sync", show=True), Binding("?", "help", "Help", show=True), + Binding("slash", "search", "Search", show=True), + Binding("escape", "clear_search", "Clear Search", show=False), Binding("enter", "view_task", "View", show=False), ] @@ -209,6 +240,7 @@ class TasksApp(App): self.notes_visible = False self.detail_visible = False self.sidebar_visible = True # Start with sidebar visible + self.current_search_query = "" # Current search filter self.config = get_config() if backend: @@ -222,6 +254,12 @@ class TasksApp(App): def compose(self) -> ComposeResult: """Create the app layout.""" yield Header() + yield Horizontal( + Static("\uf002 Search:", classes="search-label"), # nf-fa-search + Input(placeholder="Filter tasks...", id="search-input"), + id="search-container", + classes="hidden", + ) yield FilterSidebar(id="sidebar") yield Vertical( DataTable(id="task-table", cursor_type="row"), @@ -414,10 +452,11 @@ class TasksApp(App): self._update_sidebar() def _filter_tasks(self, tasks: list[Task]) -> list[Task]: - """Filter tasks by current project and tag filters using OR logic. + """Filter tasks by current project, tag filters, and search query. - If project filter is set, only show tasks from that project - If tag filters are set, show tasks that have ANY of the selected tags (OR) + - If search query is set, filter by summary, notes, project, and tags """ filtered = tasks @@ -433,6 +472,18 @@ class TasksApp(App): if any(tag in t.tags for tag in self.current_tag_filters) ] + # Filter by search query (case-insensitive match on summary, notes, project, tags) + if self.current_search_query: + query = self.current_search_query.lower() + filtered = [ + t + for t in filtered + if query in t.summary.lower() + or (t.notes and query in t.notes.lower()) + or (t.project and query in t.project.lower()) + or any(query in tag.lower() for tag in t.tags) + ] + return filtered def _update_sidebar(self) -> None: @@ -491,6 +542,8 @@ class TasksApp(App): status_bar.total_tasks = len(self.tasks) filters = [] + if self.current_search_query: + filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search if self.current_project_filter: filters.append(f"project:{self.current_project_filter}") for tag in self.current_tag_filters: @@ -760,9 +813,10 @@ class TasksApp(App): self.notify(f"Sorted by {event.column} ({direction})") def action_clear_filters(self) -> None: - """Clear all filters.""" + """Clear all filters including search.""" self.current_project_filter = None self.current_tag_filters = [] + self.current_search_query = "" # Also clear sidebar selections try: @@ -771,6 +825,15 @@ class TasksApp(App): except Exception: pass + # Clear and hide search input + try: + search_container = self.query_one("#search-container") + search_input = self.query_one("#search-input", Input) + search_input.value = "" + search_container.add_class("hidden") + except Exception: + pass + self.load_tasks() self.notify("Filters cleared", severity="information") @@ -806,6 +869,8 @@ Keybindings: x - Delete task w - Toggle filter sidebar c - Clear all filters + / - Search tasks + Esc - Clear search r - Refresh y - Sync with remote Enter - View task details @@ -813,6 +878,54 @@ Keybindings: """ self.notify(help_text.strip(), timeout=10) + # Search actions + def action_search(self) -> None: + """Show search input and focus it.""" + search_container = self.query_one("#search-container") + search_container.remove_class("hidden") + search_input = self.query_one("#search-input", Input) + search_input.focus() + + def action_clear_search(self) -> None: + """Clear search and hide search input.""" + search_container = self.query_one("#search-container") + search_input = self.query_one("#search-input", Input) + + # Only act if search is visible or there's a query + if not search_container.has_class("hidden") or self.current_search_query: + search_input.value = "" + self.current_search_query = "" + search_container.add_class("hidden") + self.load_tasks() + # Focus back to table + table = self.query_one("#task-table", DataTable) + table.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle Enter in search input - apply search and focus table.""" + if event.input.id != "search-input": + return + + query = event.value.strip() + self.current_search_query = query + self.load_tasks() + + # Focus back to table + table = self.query_one("#task-table", DataTable) + table.focus() + + if query: + self.notify(f"Searching: {query}") + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle live search as user types.""" + if event.input.id != "search-input": + return + + # Live search - filter as user types + self.current_search_query = event.value.strip() + self.load_tasks() + # Notes actions def action_toggle_notes(self) -> None: """Toggle the notes-only pane visibility."""