From 95d3098bf3d9d0c71901fb88c1c52df87183b0ff Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 19 Dec 2025 16:34:21 -0500 Subject: [PATCH] Add search feature to calendar app with / keybinding using khal search --- src/calendar/app.py | 132 +++++++++++++++++++++++++++++++++++- src/calendar/backend.py | 14 ++++ src/services/khal/client.py | 41 +++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/calendar/app.py b/src/calendar/app.py index 03fea08..e923ea6 100644 --- a/src/calendar/app.py +++ b/src/calendar/app.py @@ -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"): diff --git a/src/calendar/backend.py b/src/calendar/backend.py index dce6595..f1b437a 100644 --- a/src/calendar/backend.py +++ b/src/calendar/backend.py @@ -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 [] diff --git a/src/services/khal/client.py b/src/services/khal/client.py index e3e3976..2d53f8b 100644 --- a/src/services/khal/client.py +++ b/src/services/khal/client.py @@ -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