Add search feature to calendar app with / keybinding using khal search

This commit is contained in:
Bendt
2025-12-19 16:34:21 -05:00
parent 599507068a
commit 95d3098bf3
3 changed files with 186 additions and 1 deletions

View File

@@ -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"):

View File

@@ -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 []

View File

@@ -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