This commit is contained in:
Bendt
2025-12-18 22:11:47 -05:00
parent 0ed7800575
commit a41d59e529
26 changed files with 4187 additions and 373 deletions

401
src/calendar/app.py Normal file
View File

@@ -0,0 +1,401 @@
"""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
from textual.reactive import reactive
from src.calendar.backend import CalendarBackend, Event
from src.calendar.widgets.WeekGrid import WeekGrid
from src.calendar.widgets.AddEventForm import EventFormData
from src.utils.shared_config import get_theme_name
# 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;
}
#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;
}
"""
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("r", "refresh", "Refresh", show=True),
Binding("enter", "view_event", "View", show=True),
Binding("a", "add_event", "Add", show=True),
Binding("?", "help", "Help", show=True),
]
# Reactive attributes
include_weekends: reactive[bool] = reactive(True)
# Instance attributes
backend: Optional[CalendarBackend]
def __init__(self, backend: Optional[CalendarBackend] = None):
super().__init__()
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 WeekGrid(id="week-grid")
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()
# Load events for current week
self.load_events()
# Update status bar and title
self._update_status()
self._update_title()
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()
def on_week_grid_event_selected(self, message: WeekGrid.EventSelected) -> None:
"""Handle event selection."""
self._update_event_detail(message.event)
# 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_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)
Enter - View event details
a - Add new event
r - Refresh
q - Quit
"""
self.notify(help_text.strip(), timeout=10)
def run_app(backend: Optional[CalendarBackend] = None) -> None:
"""Run the Calendar TUI application."""
app = CalendarApp(backend=backend)
app.run()
if __name__ == "__main__":
run_app()