Add search feature to calendar app with / keybinding using khal search
This commit is contained in:
@@ -13,7 +13,7 @@ from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.logging import TextualHandler
|
||||
from textual.widgets import Footer, Header, Static
|
||||
from textual.widgets import Footer, Header, Static, Input
|
||||
from textual.reactive import reactive
|
||||
|
||||
from src.calendar.backend import CalendarBackend, Event
|
||||
@@ -116,6 +116,47 @@ class CalendarApp(App):
|
||||
#event-detail.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;
|
||||
}
|
||||
|
||||
#search-results {
|
||||
dock: bottom;
|
||||
height: 40%;
|
||||
border-top: solid $primary;
|
||||
background: $surface;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#search-results.hidden {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -133,6 +174,8 @@ class CalendarApp(App):
|
||||
Binding("r", "refresh", "Refresh", show=True),
|
||||
Binding("enter", "view_event", "View", show=True),
|
||||
Binding("a", "add_event", "Add", show=True),
|
||||
Binding("slash", "search", "Search", show=True),
|
||||
Binding("escape", "clear_search", "Clear Search", show=False),
|
||||
Binding("?", "help", "Help", show=True),
|
||||
]
|
||||
|
||||
@@ -143,10 +186,12 @@ class CalendarApp(App):
|
||||
# Instance attributes
|
||||
backend: Optional[CalendarBackend]
|
||||
_invites: list[CalendarInvite]
|
||||
_search_results: list[Event]
|
||||
|
||||
def __init__(self, backend: Optional[CalendarBackend] = None):
|
||||
super().__init__()
|
||||
self._invites = []
|
||||
self._search_results = []
|
||||
|
||||
if backend:
|
||||
self.backend = backend
|
||||
@@ -159,11 +204,18 @@ class CalendarApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create the app layout."""
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Static("\uf002 Search:", classes="search-label"), # nf-fa-search
|
||||
Input(placeholder="Search events...", id="search-input"),
|
||||
id="search-container",
|
||||
classes="hidden",
|
||||
)
|
||||
with Horizontal(id="main-content"):
|
||||
with Vertical(id="sidebar"):
|
||||
yield MonthCalendar(id="sidebar-calendar")
|
||||
yield InvitesPanel(id="sidebar-invites")
|
||||
yield WeekGrid(id="week-grid")
|
||||
yield Static(id="search-results", classes="hidden")
|
||||
yield Static(id="event-detail", classes="hidden")
|
||||
yield CalendarStatusBar(id="status-bar")
|
||||
yield Footer()
|
||||
@@ -532,6 +584,8 @@ Keybindings:
|
||||
w - Toggle weekends (5/7 days)
|
||||
s - Toggle sidebar
|
||||
i - Focus invites panel
|
||||
/ - Search events
|
||||
Esc - Clear search
|
||||
Enter - View event details
|
||||
a - Add new event
|
||||
r - Refresh
|
||||
@@ -539,6 +593,82 @@ 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 UI."""
|
||||
search_container = self.query_one("#search-container")
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
|
||||
# Only act if search is visible
|
||||
if not search_container.has_class("hidden") or not search_results.has_class(
|
||||
"hidden"
|
||||
):
|
||||
search_input.value = ""
|
||||
search_container.add_class("hidden")
|
||||
search_results.add_class("hidden")
|
||||
self._search_results = []
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Handle Enter in search input - perform search."""
|
||||
if event.input.id != "search-input":
|
||||
return
|
||||
|
||||
query = event.value.strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
self._perform_search(query)
|
||||
|
||||
def _perform_search(self, query: str) -> None:
|
||||
"""Perform event search and display results."""
|
||||
if not self.backend:
|
||||
return
|
||||
|
||||
# Check if backend has search_events method
|
||||
if not hasattr(self.backend, "search_events"):
|
||||
self.notify(
|
||||
"Search not supported by this calendar backend", severity="warning"
|
||||
)
|
||||
return
|
||||
|
||||
results = self.backend.search_events(query)
|
||||
self._search_results = results
|
||||
|
||||
# Update results display
|
||||
search_results = self.query_one("#search-results", Static)
|
||||
|
||||
if results:
|
||||
lines = [f"[b]Search results for '{query}': {len(results)} found[/b]", ""]
|
||||
for event in results[:20]: # Limit display to 20 results
|
||||
date_str = event.start.strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f" {date_str} [b]{event.title}[/b]")
|
||||
if event.location:
|
||||
lines.append(f" [dim]{event.location}[/dim]")
|
||||
if len(results) > 20:
|
||||
lines.append(f" ... and {len(results) - 20} more")
|
||||
search_results.update("\n".join(lines))
|
||||
search_results.remove_class("hidden")
|
||||
self.notify(f"Found {len(results)} event(s)")
|
||||
else:
|
||||
search_results.update(f"[b]No events found matching '{query}'[/b]")
|
||||
search_results.remove_class("hidden")
|
||||
self.notify("No events found")
|
||||
|
||||
# Focus back to grid
|
||||
grid = self.query_one("#week-grid", WeekGrid)
|
||||
grid.focus()
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Quit the app and clean up IPC listener."""
|
||||
if hasattr(self, "_ipc_listener"):
|
||||
|
||||
@@ -216,3 +216,17 @@ class CalendarBackend(ABC):
|
||||
by_date[d].sort(key=lambda e: e.start)
|
||||
|
||||
return by_date
|
||||
|
||||
def search_events(self, query: str) -> List[Event]:
|
||||
"""Search for events matching a query string.
|
||||
|
||||
Default implementation returns empty list. Override in subclasses
|
||||
that support search.
|
||||
|
||||
Args:
|
||||
query: Search string to match against event titles and descriptions
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
"""
|
||||
return []
|
||||
|
||||
@@ -330,3 +330,44 @@ class KhalClient(CalendarBackend):
|
||||
# khal edit is interactive, so this is limited via CLI
|
||||
logger.warning("update_event not fully implemented for khal CLI")
|
||||
return None
|
||||
|
||||
def search_events(self, query: str) -> List[Event]:
|
||||
"""Search for events matching a query string.
|
||||
|
||||
Args:
|
||||
query: Search string to match against event titles and descriptions
|
||||
|
||||
Returns:
|
||||
List of matching events
|
||||
"""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
# Use khal search with custom format
|
||||
format_str = "{title}|{start-time}|{end-time}|{start}|{end}|{location}|{uid}|{description}|{organizer}|{url}|{categories}|{status}|{repeat-symbol}"
|
||||
args = ["search", "-f", format_str, query]
|
||||
|
||||
result = self._run_khal(args)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"khal search failed: {result.stderr}")
|
||||
return []
|
||||
|
||||
events = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip day headers
|
||||
if ", " in line and "|" not in line:
|
||||
continue
|
||||
|
||||
event = self._parse_event_line(line)
|
||||
if event:
|
||||
events.append(event)
|
||||
|
||||
# Sort by start time
|
||||
events.sort(key=lambda e: e.start)
|
||||
|
||||
return events
|
||||
|
||||
Reference in New Issue
Block a user