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.binding import Binding
from textual.containers import Container, Horizontal, Vertical from textual.containers import Container, Horizontal, Vertical
from textual.logging import TextualHandler 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 textual.reactive import reactive
from src.calendar.backend import CalendarBackend, Event from src.calendar.backend import CalendarBackend, Event
@@ -116,6 +116,47 @@ class CalendarApp(App):
#event-detail.hidden { #event-detail.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;
}
#search-results {
dock: bottom;
height: 40%;
border-top: solid $primary;
background: $surface;
padding: 1;
}
#search-results.hidden {
display: none;
}
""" """
BINDINGS = [ BINDINGS = [
@@ -133,6 +174,8 @@ class CalendarApp(App):
Binding("r", "refresh", "Refresh", show=True), Binding("r", "refresh", "Refresh", show=True),
Binding("enter", "view_event", "View", show=True), Binding("enter", "view_event", "View", show=True),
Binding("a", "add_event", "Add", 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), Binding("?", "help", "Help", show=True),
] ]
@@ -143,10 +186,12 @@ class CalendarApp(App):
# Instance attributes # Instance attributes
backend: Optional[CalendarBackend] backend: Optional[CalendarBackend]
_invites: list[CalendarInvite] _invites: list[CalendarInvite]
_search_results: list[Event]
def __init__(self, backend: Optional[CalendarBackend] = None): def __init__(self, backend: Optional[CalendarBackend] = None):
super().__init__() super().__init__()
self._invites = [] self._invites = []
self._search_results = []
if backend: if backend:
self.backend = backend self.backend = backend
@@ -159,11 +204,18 @@ class CalendarApp(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="Search events...", id="search-input"),
id="search-container",
classes="hidden",
)
with Horizontal(id="main-content"): with Horizontal(id="main-content"):
with Vertical(id="sidebar"): with Vertical(id="sidebar"):
yield MonthCalendar(id="sidebar-calendar") yield MonthCalendar(id="sidebar-calendar")
yield InvitesPanel(id="sidebar-invites") yield InvitesPanel(id="sidebar-invites")
yield WeekGrid(id="week-grid") yield WeekGrid(id="week-grid")
yield Static(id="search-results", classes="hidden")
yield Static(id="event-detail", classes="hidden") yield Static(id="event-detail", classes="hidden")
yield CalendarStatusBar(id="status-bar") yield CalendarStatusBar(id="status-bar")
yield Footer() yield Footer()
@@ -532,6 +584,8 @@ Keybindings:
w - Toggle weekends (5/7 days) w - Toggle weekends (5/7 days)
s - Toggle sidebar s - Toggle sidebar
i - Focus invites panel i - Focus invites panel
/ - Search events
Esc - Clear search
Enter - View event details Enter - View event details
a - Add new event a - Add new event
r - Refresh r - Refresh
@@ -539,6 +593,82 @@ 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 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: async def action_quit(self) -> None:
"""Quit the app and clean up IPC listener.""" """Quit the app and clean up IPC listener."""
if hasattr(self, "_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) by_date[d].sort(key=lambda e: e.start)
return by_date 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 # khal edit is interactive, so this is limited via CLI
logger.warning("update_event not fully implemented for khal CLI") logger.warning("update_event not fully implemented for khal CLI")
return None 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