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.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"):
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user