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.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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user