"""Calendar TUI application. A Textual-based TUI for viewing calendar events via khal. """ import logging import sys import os from datetime import date, datetime, timedelta from typing import Optional 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, Input from textual.reactive import reactive from src.calendar.backend import CalendarBackend, Event from src.calendar.widgets.WeekGrid import WeekGrid from src.calendar.widgets.MonthCalendar import MonthCalendar from src.calendar.widgets.InvitesPanel import InvitesPanel, CalendarInvite from src.calendar.widgets.AddEventForm import EventFormData from src.utils.shared_config import get_theme_name from src.utils.ipc import IPCListener, IPCMessage # Add the parent directory to the system path to resolve relative imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) logging.basicConfig( level="NOTSET", handlers=[TextualHandler()], ) logger = logging.getLogger(__name__) class CalendarStatusBar(Static): """Status bar showing current week and selected event.""" week_label: str = "" event_info: str = "" def render(self) -> str: if self.event_info: return f"{self.week_label} | {self.event_info}" return self.week_label class CalendarApp(App): """A TUI for viewing calendar events via khal.""" CSS = """ Screen { layout: vertical; } #main-content { layout: horizontal; height: 1fr; } #sidebar { width: 26; border-right: solid $surface-darken-1; background: $surface; padding: 1 0; } #sidebar.hidden { display: none; } #sidebar-calendar { height: auto; } #sidebar-invites { height: auto; margin-top: 1; border-top: solid $surface-darken-1; padding-top: 1; } #week-grid { height: 1fr; } #week-grid > WeekGridHeader { height: 1; dock: top; background: $surface; } #week-grid > WeekGridBody { height: 1fr; } #status-bar { dock: bottom; height: 1; background: $surface; color: $text-muted; padding: 0 1; } #event-detail { dock: bottom; height: auto; max-height: 12; border-top: solid $primary; padding: 1; background: $surface; } #event-detail.hidden { display: none; } #search-container { dock: top; height: 4; width: 100%; background: $surface; border-bottom: solid $primary; padding: 0 1; align: left middle; } #search-container.hidden { display: none; } #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 = [ Binding("q", "quit", "Quit", show=True), Binding("j", "cursor_down", "Down", show=False), Binding("k", "cursor_up", "Up", show=False), Binding("h", "cursor_left", "Left", show=False), Binding("l", "cursor_right", "Right", show=False), Binding("H", "prev_week", "Prev Week", show=True), Binding("L", "next_week", "Next Week", show=True), Binding("g", "goto_today", "Today", show=True), Binding("w", "toggle_weekends", "Weekends", show=True), Binding("s", "toggle_sidebar", "Sidebar", show=True), Binding("i", "focus_invites", "Invites", show=True), 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), ] # Reactive attributes include_weekends: reactive[bool] = reactive(True) show_sidebar: reactive[bool] = reactive(True) # 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 else: # Create backend from config (default: khal) from src.services.khal import KhalClient self.backend = KhalClient() 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() def on_mount(self) -> None: """Initialize the app on mount.""" self.theme = get_theme_name() # Start IPC listener for refresh notifications from sync daemon self._ipc_listener = IPCListener("calendar", self._on_ipc_message) self._ipc_listener.start() # Load events for current week self.load_events() # Sync sidebar calendar with current week self._sync_sidebar_calendar() # Load invites in background self.run_worker(self._load_invites_async(), exclusive=True) # Update status bar and title self._update_status() self._update_title() # Focus the week grid (not the hidden search input) self.query_one("#week-grid", WeekGrid).focus() def _on_ipc_message(self, message: IPCMessage) -> None: """Handle IPC messages from sync daemon.""" if message.event == "refresh": # Schedule a reload on the main thread self.call_from_thread(self.load_events) async def _load_invites_async(self) -> None: """Load pending calendar invites from Microsoft Graph.""" try: from src.services.microsoft_graph.auth import get_access_token from src.services.microsoft_graph.calendar import fetch_pending_invites from dateutil import parser as date_parser # Get auth token scopes = ["https://graph.microsoft.com/Calendars.Read"] _, headers = get_access_token(scopes) # Fetch invites raw_invites = await fetch_pending_invites(headers, days_forward=30) # Convert to CalendarInvite objects invites = [] for inv in raw_invites: try: start_str = inv.get("start", {}).get("dateTime", "") end_str = inv.get("end", {}).get("dateTime", "") start_dt = ( date_parser.parse(start_str) if start_str else datetime.now() ) end_dt = date_parser.parse(end_str) if end_str else start_dt organizer_data = inv.get("organizer", {}).get("emailAddress", {}) organizer_name = organizer_data.get( "name", organizer_data.get("address", "Unknown") ) invite = CalendarInvite( id=inv.get("id", ""), subject=inv.get("subject", "No Subject"), organizer=organizer_name, start=start_dt, end=end_dt, location=inv.get("location", {}).get("displayName"), is_all_day=inv.get("isAllDay", False), response_status=inv.get("responseStatus", {}).get( "response", "notResponded" ), ) invites.append(invite) except Exception as e: logger.warning(f"Failed to parse invite: {e}") # Update the panel self._invites = invites self.call_from_thread(self._update_invites_panel) except Exception as e: logger.warning(f"Failed to load invites: {e}") # Silently fail - invites are optional def _update_invites_panel(self) -> None: """Update the invites panel with loaded invites.""" try: panel = self.query_one("#sidebar-invites", InvitesPanel) panel.set_invites(self._invites) except Exception: pass def _sync_sidebar_calendar(self) -> None: """Sync the sidebar calendar with the main week grid.""" try: grid = self.query_one("#week-grid", WeekGrid) calendar = self.query_one("#sidebar-calendar", MonthCalendar) calendar.update_week(grid.week_start) calendar.update_selected(grid.get_cursor_date()) except Exception: pass # Sidebar might not exist yet def load_events(self) -> None: """Load events from backend for the current week.""" if not self.backend: return grid = self.query_one("#week-grid", WeekGrid) week_start = grid.week_start # Get events using backend's helper method events_by_date = self.backend.get_week_events( week_start, include_weekends=self.include_weekends ) # Set events on grid grid.set_events(events_by_date) # Update status bar with week label self._update_status() def _update_status(self) -> None: """Update the status bar.""" grid = self.query_one("#week-grid", WeekGrid) status = self.query_one("#status-bar", CalendarStatusBar) # Week label week_start = grid.week_start week_end = week_start + timedelta(days=6) status.week_label = ( f"Week of {week_start.strftime('%b %d')} - {week_end.strftime('%b %d, %Y')}" ) # Event info event = grid.get_event_at_cursor() if event: time_str = event.start.strftime("%H:%M") + "-" + event.end.strftime("%H:%M") status.event_info = f"{time_str} {event.title}" else: status.event_info = "" status.refresh() # Also update title when status changes self._update_title() def _update_title(self) -> None: """Update the app title with full date range and week number.""" grid = self.query_one("#week-grid", WeekGrid) week_start = grid.week_start week_end = week_start + timedelta(days=6) week_num = week_start.isocalendar()[1] # Format: "2025 December 14 - 20 (Week 48)" if week_start.month == week_end.month: # Same month self.title = ( f"{week_start.year} {week_start.strftime('%B')} " f"{week_start.day} - {week_end.day} (Week {week_num})" ) else: # Different months self.title = ( f"{week_start.strftime('%B %d')} - " f"{week_end.strftime('%B %d, %Y')} (Week {week_num})" ) def _update_event_detail(self, event: Optional[Event]) -> None: """Update the event detail pane.""" detail = self.query_one("#event-detail", Static) if event: detail.remove_class("hidden") # Format event details date_str = event.start.strftime("%A, %B %d") time_str = ( event.start.strftime("%H:%M") + " - " + event.end.strftime("%H:%M") ) duration = event.duration_minutes hours, mins = divmod(duration, 60) dur_str = f"{hours}h {mins}m" if hours else f"{mins}m" lines = [ f"[bold]{event.title}[/bold]", f"{date_str}", f"{time_str} ({dur_str})", ] if event.location: lines.append(f"[dim]Location:[/dim] {event.location}") if event.organizer: lines.append(f"[dim]Organizer:[/dim] {event.organizer}") if event.categories: lines.append(f"[dim]Categories:[/dim] {event.categories}") if event.url: lines.append(f"[dim]URL:[/dim] {event.url}") if event.status: lines.append(f"[dim]Status:[/dim] {event.status}") if event.recurring: lines.append("[dim]Recurring:[/dim] Yes") if event.description: # Truncate long descriptions desc = ( event.description[:200] + "..." if len(event.description) > 200 else event.description ) lines.append(f"[dim]Description:[/dim] {desc}") detail.update("\n".join(lines)) else: detail.add_class("hidden") # Handle WeekGrid messages def on_week_grid_week_changed(self, message: WeekGrid.WeekChanged) -> None: """Handle week change - reload events.""" self.load_events() self._sync_sidebar_calendar() def on_week_grid_event_selected(self, message: WeekGrid.EventSelected) -> None: """Handle event selection.""" self._update_event_detail(message.event) # Handle MonthCalendar messages def on_month_calendar_date_selected( self, message: MonthCalendar.DateSelected ) -> None: """Handle date selection from sidebar calendar.""" grid = self.query_one("#week-grid", WeekGrid) grid.goto_date(message.date) self.load_events() self._sync_sidebar_calendar() # Navigation actions (forwarded to grid) def action_cursor_down(self) -> None: """Move cursor down.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_cursor_down() self._update_status() def action_cursor_up(self) -> None: """Move cursor up.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_cursor_up() self._update_status() def action_cursor_left(self) -> None: """Move cursor left.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_cursor_left() self._update_status() def action_cursor_right(self) -> None: """Move cursor right.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_cursor_right() self._update_status() def action_prev_week(self) -> None: """Navigate to previous week.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_prev_week() def action_next_week(self) -> None: """Navigate to next week.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_next_week() def action_goto_today(self) -> None: """Navigate to today.""" grid = self.query_one("#week-grid", WeekGrid) grid.action_goto_today() self.load_events() def action_toggle_weekends(self) -> None: """Toggle weekend display.""" self.include_weekends = not self.include_weekends grid = self.query_one("#week-grid", WeekGrid) grid.include_weekends = self.include_weekends self.load_events() mode = "7 days" if self.include_weekends else "5 days (weekdays)" self.notify(f"Showing {mode}") def action_toggle_sidebar(self) -> None: """Toggle sidebar visibility.""" self.show_sidebar = not self.show_sidebar sidebar = self.query_one("#sidebar", Vertical) if self.show_sidebar: sidebar.remove_class("hidden") else: sidebar.add_class("hidden") def action_refresh(self) -> None: """Refresh events from backend.""" self.load_events() self.notify("Refreshed") def action_view_event(self) -> None: """View the selected event details.""" grid = self.query_one("#week-grid", WeekGrid) event = grid.get_event_at_cursor() if event: self._update_event_detail(event) else: self.notify("No event at cursor") def action_add_event(self) -> None: """Open the add event modal.""" from src.calendar.screens.AddEventScreen import AddEventScreen # Get calendars from backend calendars: list[str] = [] if self.backend: try: calendars = self.backend.get_calendars() except Exception: pass # Get current cursor date/time for initial values grid = self.query_one("#week-grid", WeekGrid) cursor_date = grid.get_cursor_date() cursor_time = grid.get_cursor_time() def handle_result(data: EventFormData | None) -> None: if data is None: return if not self.backend: self.notify("No calendar backend available", severity="error") return try: self.backend.create_event( title=data.title, start=data.start_datetime, end=data.end_datetime, calendar=data.calendar, location=data.location, description=data.description, all_day=data.all_day, ) self.notify(f"Created event: {data.title}") self.load_events() # Refresh to show new event except Exception as e: self.notify(f"Failed to create event: {e}", severity="error") self.push_screen( AddEventScreen( calendars=calendars, initial_date=cursor_date, initial_time=cursor_time, ), handle_result, ) def action_help(self) -> None: """Show help.""" help_text = """ Keybindings: j/k - Move cursor up/down (time) h/l - Move cursor left/right (day) H/L - Previous/Next week g - Go to today 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 q - Quit """ 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"): self._ipc_listener.stop() self.exit() def action_focus_invites(self) -> None: """Focus on the invites panel and show invite count.""" if not self.show_sidebar: self.action_toggle_sidebar() if self._invites: self.notify(f"You have {len(self._invites)} pending invite(s)") else: self.notify("No pending invites") def run_app(backend: Optional[CalendarBackend] = None) -> None: """Run the Calendar TUI application.""" app = CalendarApp(backend=backend) app.run() if __name__ == "__main__": run_app()