Files
luk/src/calendar/app.py

695 lines
22 KiB
Python

"""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()