WIP
This commit is contained in:
401
src/calendar/app.py
Normal file
401
src/calendar/app.py
Normal 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()
|
||||
Reference in New Issue
Block a user