Add search feature to tasks app with / keybinding and live filtering

This commit is contained in:
Bendt
2025-12-19 16:30:45 -05:00
parent 1337d84369
commit 505fdbcd3d

View File

@@ -12,7 +12,7 @@ from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import ScrollableContainer, Vertical, Horizontal from textual.containers import ScrollableContainer, Vertical, Horizontal
from textual.logging import TextualHandler 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 .config import get_config, TasksAppConfig
from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project from .backend import Task, TaskBackend, TaskPriority, TaskStatus, Project
@@ -154,6 +154,35 @@ class TasksApp(App):
#notes-pane.hidden { #notes-pane.hidden {
display: none; 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 = [ BINDINGS = [
@@ -175,6 +204,8 @@ class TasksApp(App):
Binding("r", "refresh", "Refresh", show=True), Binding("r", "refresh", "Refresh", show=True),
Binding("y", "sync", "Sync", show=True), Binding("y", "sync", "Sync", show=True),
Binding("?", "help", "Help", 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), Binding("enter", "view_task", "View", show=False),
] ]
@@ -209,6 +240,7 @@ class TasksApp(App):
self.notes_visible = False self.notes_visible = False
self.detail_visible = False self.detail_visible = False
self.sidebar_visible = True # Start with sidebar visible self.sidebar_visible = True # Start with sidebar visible
self.current_search_query = "" # Current search filter
self.config = get_config() self.config = get_config()
if backend: if backend:
@@ -222,6 +254,12 @@ class TasksApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the app layout.""" """Create the app layout."""
yield Header() 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 FilterSidebar(id="sidebar")
yield Vertical( yield Vertical(
DataTable(id="task-table", cursor_type="row"), DataTable(id="task-table", cursor_type="row"),
@@ -414,10 +452,11 @@ class TasksApp(App):
self._update_sidebar() self._update_sidebar()
def _filter_tasks(self, tasks: list[Task]) -> list[Task]: 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 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 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 filtered = tasks
@@ -433,6 +472,18 @@ class TasksApp(App):
if any(tag in t.tags for tag in self.current_tag_filters) 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 return filtered
def _update_sidebar(self) -> None: def _update_sidebar(self) -> None:
@@ -491,6 +542,8 @@ class TasksApp(App):
status_bar.total_tasks = len(self.tasks) status_bar.total_tasks = len(self.tasks)
filters = [] filters = []
if self.current_search_query:
filters.append(f"\uf002 {self.current_search_query}") # nf-fa-search
if self.current_project_filter: if self.current_project_filter:
filters.append(f"project:{self.current_project_filter}") filters.append(f"project:{self.current_project_filter}")
for tag in self.current_tag_filters: for tag in self.current_tag_filters:
@@ -760,9 +813,10 @@ class TasksApp(App):
self.notify(f"Sorted by {event.column} ({direction})") self.notify(f"Sorted by {event.column} ({direction})")
def action_clear_filters(self) -> None: def action_clear_filters(self) -> None:
"""Clear all filters.""" """Clear all filters including search."""
self.current_project_filter = None self.current_project_filter = None
self.current_tag_filters = [] self.current_tag_filters = []
self.current_search_query = ""
# Also clear sidebar selections # Also clear sidebar selections
try: try:
@@ -771,6 +825,15 @@ class TasksApp(App):
except Exception: except Exception:
pass 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.load_tasks()
self.notify("Filters cleared", severity="information") self.notify("Filters cleared", severity="information")
@@ -806,6 +869,8 @@ Keybindings:
x - Delete task x - Delete task
w - Toggle filter sidebar w - Toggle filter sidebar
c - Clear all filters c - Clear all filters
/ - Search tasks
Esc - Clear search
r - Refresh r - Refresh
y - Sync with remote y - Sync with remote
Enter - View task details Enter - View task details
@@ -813,6 +878,54 @@ Keybindings:
""" """
self.notify(help_text.strip(), timeout=10) 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 # Notes actions
def action_toggle_notes(self) -> None: def action_toggle_notes(self) -> None:
"""Toggle the notes-only pane visibility.""" """Toggle the notes-only pane visibility."""