Add search feature to tasks app with / keybinding and live filtering
This commit is contained in:
119
src/tasks/app.py
119
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."""
|
||||
|
||||
Reference in New Issue
Block a user