695 lines
22 KiB
Python
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()
|