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

6
src/calendar/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Calendar TUI package."""
from .backend import CalendarBackend, Event
from .app import CalendarApp, run_app
__all__ = ["CalendarBackend", "Event", "CalendarApp", "run_app"]

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

218
src/calendar/backend.py Normal file
View File

@@ -0,0 +1,218 @@
"""Calendar backend abstraction for Calendar TUI.
This module defines the abstract interface that all calendar backends must implement,
allowing the TUI to work with different calendar systems (khal, calcurse, etc.)
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, date, time, timedelta
from typing import Optional, List, Tuple
@dataclass
class Event:
"""Unified calendar event representation across backends."""
uid: str
title: str
start: datetime
end: datetime
location: str = ""
description: str = ""
calendar: str = ""
all_day: bool = False
recurring: bool = False
organizer: str = ""
url: str = ""
categories: str = ""
status: str = "" # CONFIRMED, TENTATIVE, CANCELLED
@property
def duration_minutes(self) -> int:
"""Get duration in minutes."""
delta = self.end - self.start
return int(delta.total_seconds() / 60)
@property
def start_time(self) -> time:
"""Get start time."""
return self.start.time()
@property
def end_time(self) -> time:
"""Get end time."""
return self.end.time()
@property
def date(self) -> date:
"""Get the date of the event."""
return self.start.date()
def overlaps(self, other: "Event") -> bool:
"""Check if this event overlaps with another."""
return self.start < other.end and self.end > other.start
def get_row_span(self, minutes_per_row: int = 30) -> Tuple[int, int]:
"""Get the row range for this event in a grid.
Args:
minutes_per_row: Minutes each row represents (default 30)
Returns:
Tuple of (start_row, end_row) where rows are 0-indexed from midnight
"""
start_minutes = self.start.hour * 60 + self.start.minute
end_minutes = self.end.hour * 60 + self.end.minute
# Handle events ending at midnight (next day)
if end_minutes == 0 and self.end.date() > self.start.date():
end_minutes = 24 * 60
start_row = start_minutes // minutes_per_row
end_row = (end_minutes + minutes_per_row - 1) // minutes_per_row # Round up
return start_row, end_row
class CalendarBackend(ABC):
"""Abstract base class for calendar backends."""
@abstractmethod
def get_events(
self,
start_date: date,
end_date: date,
calendar: Optional[str] = None,
) -> List[Event]:
"""Get events in a date range.
Args:
start_date: Start of range (inclusive)
end_date: End of range (inclusive)
calendar: Optional calendar name to filter by
Returns:
List of events in the range, sorted by start time
"""
pass
@abstractmethod
def get_event(self, uid: str) -> Optional[Event]:
"""Get a single event by UID.
Args:
uid: Event unique identifier
Returns:
Event if found, None otherwise
"""
pass
@abstractmethod
def get_calendars(self) -> List[str]:
"""Get list of available calendar names.
Returns:
List of calendar names
"""
pass
@abstractmethod
def create_event(
self,
title: str,
start: datetime,
end: datetime,
calendar: Optional[str] = None,
location: Optional[str] = None,
description: Optional[str] = None,
all_day: bool = False,
) -> Event:
"""Create a new event.
Args:
title: Event title
start: Start datetime
end: End datetime
calendar: Calendar to add event to
location: Event location
description: Event description
all_day: Whether this is an all-day event
Returns:
The created event
"""
pass
@abstractmethod
def delete_event(self, uid: str) -> bool:
"""Delete an event.
Args:
uid: Event unique identifier
Returns:
True if deleted successfully
"""
pass
@abstractmethod
def update_event(
self,
uid: str,
title: Optional[str] = None,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
location: Optional[str] = None,
description: Optional[str] = None,
) -> Optional[Event]:
"""Update an existing event.
Args:
uid: Event unique identifier
title: New title (if provided)
start: New start time (if provided)
end: New end time (if provided)
location: New location (if provided)
description: New description (if provided)
Returns:
Updated event if successful, None otherwise
"""
pass
def get_week_events(
self,
week_start: date,
include_weekends: bool = True,
) -> dict[date, List[Event]]:
"""Get events for a week, grouped by date.
Args:
week_start: First day of the week
include_weekends: Whether to include Saturday/Sunday
Returns:
Dict mapping dates to lists of events
"""
days = 7 if include_weekends else 5
end_date = week_start + timedelta(days=days - 1)
events = self.get_events(week_start, end_date)
# Group by date
by_date: dict[date, List[Event]] = {}
for i in range(days):
d = week_start + timedelta(days=i)
by_date[d] = []
for event in events:
event_date = event.date
if event_date in by_date:
by_date[event_date].append(event)
# Sort each day's events by start time
for d in by_date:
by_date[d].sort(key=lambda e: e.start)
return by_date

110
src/calendar/config.py Normal file
View File

@@ -0,0 +1,110 @@
"""Calendar TUI configuration."""
import os
from pathlib import Path
from typing import Optional
try:
import toml
except ImportError:
toml = None # type: ignore
# Default configuration values
DEFAULT_CONFIG = {
"display": {
"work_day_start_hour": 7, # 7 AM
"work_day_end_hour": 19, # 7 PM
"include_weekends": True,
"minutes_per_row": 30,
"day_column_width": 20,
"week_start_day": 0, # 0=Sunday, 1=Monday, ..., 6=Saturday
},
"backend": {
"type": "khal", # khal, calcurse, etc.
"calendar_path": "~/Calendar/corteva",
},
"theme": {
"event_color": "blue",
"overlap_color": "dark_orange",
"cursor_style": "reverse",
"work_hours_time_color": "blue",
"off_hours_time_color": "bright_black",
},
}
def get_config_path() -> Path:
"""Get the calendar config file path."""
# Check XDG_CONFIG_HOME first, then fall back to ~/.config
config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
return Path(config_home) / "luk" / "calendar.toml"
def load_config() -> dict:
"""Load calendar configuration from TOML file.
Returns merged config with defaults for any missing values.
"""
config = DEFAULT_CONFIG.copy()
config_path = get_config_path()
if config_path.exists() and toml is not None:
try:
user_config = toml.load(config_path)
# Deep merge user config into defaults
for section, values in user_config.items():
if section in config and isinstance(config[section], dict):
config[section].update(values)
else:
config[section] = values
except Exception:
pass # Use defaults on error
return config
def get_display_config() -> dict:
"""Get display-related configuration."""
return load_config().get("display", DEFAULT_CONFIG["display"])
def get_backend_config() -> dict:
"""Get backend-related configuration."""
return load_config().get("backend", DEFAULT_CONFIG["backend"])
def get_theme_config() -> dict:
"""Get theme-related configuration."""
return load_config().get("theme", DEFAULT_CONFIG["theme"])
# Convenience accessors
def work_day_start_hour() -> int:
"""Get the work day start hour (for initial scroll position)."""
return get_display_config().get("work_day_start_hour", 7)
def work_day_end_hour() -> int:
"""Get the work day end hour."""
return get_display_config().get("work_day_end_hour", 19)
def include_weekends_default() -> bool:
"""Get default for including weekends."""
return get_display_config().get("include_weekends", True)
def minutes_per_row() -> int:
"""Get minutes per row (default 30)."""
return get_display_config().get("minutes_per_row", 30)
def day_column_width() -> int:
"""Get day column width."""
return get_display_config().get("day_column_width", 20)
def week_start_day() -> int:
"""Get the week start day (0=Sunday, 1=Monday, ..., 6=Saturday)."""
return get_display_config().get("week_start_day", 0)

View File

@@ -0,0 +1,155 @@
"""Add Event modal screen for Calendar TUI."""
from datetime import date, time
from typing import Optional
from textual import on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
from src.calendar.widgets.AddEventForm import AddEventForm, EventFormData
class AddEventScreen(ModalScreen[Optional[EventFormData]]):
"""Modal screen for adding a new calendar event."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("ctrl+s", "submit", "Save"),
]
DEFAULT_CSS = """
AddEventScreen {
align: center middle;
}
AddEventScreen #add-event-container {
width: 80%;
height: auto;
max-height: 85%;
background: $surface;
border: thick $primary;
padding: 1;
}
AddEventScreen #add-event-title {
text-style: bold;
width: 100%;
height: 1;
text-align: center;
margin-bottom: 1;
}
AddEventScreen #add-event-content {
width: 100%;
height: auto;
}
AddEventScreen #add-event-form {
width: 1fr;
}
AddEventScreen #add-event-sidebar {
width: 16;
height: auto;
padding: 1;
align: center top;
}
AddEventScreen #add-event-sidebar Button {
width: 100%;
margin-bottom: 1;
}
AddEventScreen #help-text {
width: 100%;
height: 1;
color: $text-muted;
text-align: center;
margin-top: 1;
}
"""
def __init__(
self,
calendars: list[str] | None = None,
initial_date: date | None = None,
initial_time: time | None = None,
initial_data: EventFormData | None = None,
**kwargs,
):
"""Initialize the add event screen.
Args:
calendars: List of available calendar names for the dropdown
initial_date: Pre-populate with this date
initial_time: Pre-populate with this time
initial_data: Pre-populate form with this data (overrides date/time)
"""
super().__init__(**kwargs)
self._calendars = calendars or []
self._initial_date = initial_date
self._initial_time = initial_time
self._initial_data = initial_data
def compose(self) -> ComposeResult:
with Vertical(id="add-event-container"):
yield Label("Add New Event", id="add-event-title")
with Horizontal(id="add-event-content"):
yield AddEventForm(
calendars=self._calendars,
initial_date=self._initial_date,
initial_time=self._initial_time,
initial_data=self._initial_data,
id="add-event-form",
)
with Vertical(id="add-event-sidebar"):
yield Button("Create", id="create", variant="primary")
yield Button("Cancel", id="cancel", variant="default")
yield Label("Ctrl+S to save, Escape to cancel", id="help-text")
def on_mount(self) -> None:
"""Focus the title input."""
try:
form = self.query_one("#add-event-form", AddEventForm)
title_input = form.query_one("#title-input")
title_input.focus()
except Exception:
pass
@on(Button.Pressed, "#create")
def handle_create(self) -> None:
"""Handle create button press."""
self.action_submit()
@on(Button.Pressed, "#cancel")
def handle_cancel(self) -> None:
"""Handle cancel button press."""
self.action_cancel()
@on(Input.Submitted, "#title-input")
def handle_title_submit(self) -> None:
"""Handle Enter key in title input."""
self.action_submit()
def action_submit(self) -> None:
"""Validate and submit the form."""
form = self.query_one("#add-event-form", AddEventForm)
is_valid, error = form.validate()
if not is_valid:
self.notify(error, severity="error")
return
data = form.get_form_data()
self.dismiss(data)
def action_cancel(self) -> None:
"""Cancel and dismiss."""
self.dismiss(None)

View File

@@ -0,0 +1,5 @@
"""Calendar TUI screens."""
from .AddEventScreen import AddEventScreen
__all__ = ["AddEventScreen"]

View File

@@ -0,0 +1,377 @@
"""Reusable Add Event form widget for Calendar TUI.
This widget can be used standalone in modals or embedded in other screens.
"""
from dataclasses import dataclass
from datetime import datetime, date, time, timedelta
from typing import Optional
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, ScrollableContainer
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, Label, Select, TextArea, Checkbox, MaskedInput
@dataclass
class EventFormData:
"""Data from the add event form."""
title: str
start_date: date
start_time: time
end_date: date
end_time: time
location: Optional[str] = None
description: Optional[str] = None
calendar: Optional[str] = None
all_day: bool = False
@property
def start_datetime(self) -> datetime:
"""Get start as datetime."""
return datetime.combine(self.start_date, self.start_time)
@property
def end_datetime(self) -> datetime:
"""Get end as datetime."""
return datetime.combine(self.end_date, self.end_time)
class AddEventForm(Widget):
"""A reusable form widget for creating/editing calendar events.
This widget emits EventFormData when submitted and can be embedded
in various contexts (modal screens, sidebars, etc.)
"""
DEFAULT_CSS = """
AddEventForm {
width: 100%;
height: auto;
padding: 1;
}
AddEventForm ScrollableContainer {
height: auto;
max-height: 100%;
}
AddEventForm .form-row {
width: 100%;
height: auto;
margin-bottom: 1;
}
AddEventForm .form-label {
width: 12;
height: 1;
padding-right: 1;
}
AddEventForm .form-input {
width: 1fr;
}
AddEventForm #title-input {
width: 1fr;
}
AddEventForm .date-input {
width: 14;
}
AddEventForm .time-input {
width: 10;
}
AddEventForm #calendar-select {
width: 1fr;
}
AddEventForm #location-input {
width: 1fr;
}
AddEventForm #description-textarea {
width: 1fr;
height: 6;
}
AddEventForm .required {
color: $error;
}
AddEventForm .datetime-row {
width: 100%;
height: auto;
}
AddEventForm .datetime-group {
width: auto;
height: auto;
margin-right: 2;
}
AddEventForm .datetime-label {
width: auto;
padding-right: 1;
color: $text-muted;
}
"""
class Submitted(Message):
"""Message emitted when the form is submitted."""
def __init__(self, data: EventFormData) -> None:
super().__init__()
self.data = data
class Cancelled(Message):
"""Message emitted when the form is cancelled."""
pass
def __init__(
self,
calendars: list[str] | None = None,
initial_date: date | None = None,
initial_time: time | None = None,
initial_data: EventFormData | None = None,
**kwargs,
):
"""Initialize the add event form.
Args:
calendars: List of available calendar names for the dropdown
initial_date: Pre-populate with this date
initial_time: Pre-populate with this time
initial_data: Pre-populate form with this data (overrides date/time)
"""
super().__init__(**kwargs)
self._calendars = calendars or []
self._initial_date = initial_date or date.today()
self._initial_time = initial_time or time(9, 0)
self._initial_data = initial_data
def compose(self) -> ComposeResult:
"""Compose the form layout."""
if self._initial_data:
initial = self._initial_data
start_date = initial.start_date
start_time = initial.start_time
end_date = initial.end_date
end_time = initial.end_time
title = initial.title
location = initial.location or ""
description = initial.description or ""
calendar = initial.calendar or ""
all_day = initial.all_day
else:
start_date = self._initial_date
start_time = self._initial_time
# Default to 1 hour duration
end_date = start_date
end_time = time(start_time.hour + 1, start_time.minute)
if start_time.hour >= 23:
end_time = time(23, 59)
title = ""
location = ""
description = ""
calendar = ""
all_day = False
with ScrollableContainer():
# Title (required)
with Horizontal(classes="form-row"):
yield Label("Title", classes="form-label")
yield Label("*", classes="required")
yield Input(
value=title,
placeholder="Event title...",
id="title-input",
classes="form-input",
)
# Start Date/Time
with Vertical(classes="form-row"):
yield Label("Start", classes="form-label")
with Horizontal(classes="datetime-row"):
with Horizontal(classes="datetime-group"):
yield Label("Date:", classes="datetime-label")
yield MaskedInput(
template="9999-99-99",
value=start_date.strftime("%Y-%m-%d"),
id="start-date-input",
classes="date-input",
)
with Horizontal(classes="datetime-group"):
yield Label("Time:", classes="datetime-label")
yield MaskedInput(
template="99:99",
value=start_time.strftime("%H:%M"),
id="start-time-input",
classes="time-input",
)
# End Date/Time
with Vertical(classes="form-row"):
yield Label("End", classes="form-label")
with Horizontal(classes="datetime-row"):
with Horizontal(classes="datetime-group"):
yield Label("Date:", classes="datetime-label")
yield MaskedInput(
template="9999-99-99",
value=end_date.strftime("%Y-%m-%d"),
id="end-date-input",
classes="date-input",
)
with Horizontal(classes="datetime-group"):
yield Label("Time:", classes="datetime-label")
yield MaskedInput(
template="99:99",
value=end_time.strftime("%H:%M"),
id="end-time-input",
classes="time-input",
)
# All day checkbox
with Horizontal(classes="form-row"):
yield Label("", classes="form-label")
yield Checkbox("All day event", value=all_day, id="all-day-checkbox")
# Calendar selection (optional dropdown)
if self._calendars:
with Horizontal(classes="form-row"):
yield Label("Calendar", classes="form-label")
options = [("(default)", "")] + [(c, c) for c in self._calendars]
yield Select(
options=options,
value=calendar,
id="calendar-select",
allow_blank=True,
)
# Location (optional)
with Horizontal(classes="form-row"):
yield Label("Location", classes="form-label")
yield Input(
value=location,
placeholder="Event location...",
id="location-input",
classes="form-input",
)
# Description (optional textarea)
with Vertical(classes="form-row"):
yield Label("Description", classes="form-label")
yield TextArea(
description,
id="description-textarea",
)
def get_form_data(self) -> EventFormData:
"""Extract current form data.
Returns:
EventFormData with current form values
"""
title = self.query_one("#title-input", Input).value.strip()
# Parse start date/time from MaskedInput
start_date_input = self.query_one("#start-date-input", MaskedInput)
start_time_input = self.query_one("#start-time-input", MaskedInput)
start_date_str = start_date_input.value.strip()
start_time_str = start_time_input.value.strip()
try:
start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
except ValueError:
start_date = date.today()
try:
start_time = datetime.strptime(start_time_str, "%H:%M").time()
except ValueError:
start_time = time(9, 0)
# Parse end date/time from MaskedInput
end_date_input = self.query_one("#end-date-input", MaskedInput)
end_time_input = self.query_one("#end-time-input", MaskedInput)
end_date_str = end_date_input.value.strip()
end_time_str = end_time_input.value.strip()
try:
end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
except ValueError:
end_date = start_date
try:
end_time = datetime.strptime(end_time_str, "%H:%M").time()
except ValueError:
end_time = time(start_time.hour + 1, start_time.minute)
# All day
all_day = self.query_one("#all-day-checkbox", Checkbox).value
# Calendar
calendar: str | None = None
try:
calendar_select = self.query_one("#calendar-select", Select)
cal_value = calendar_select.value
if isinstance(cal_value, str) and cal_value:
calendar = cal_value
except Exception:
pass # No calendar select
# Location
location = self.query_one("#location-input", Input).value.strip() or None
# Description
try:
desc_area = self.query_one("#description-textarea", TextArea)
description = desc_area.text.strip() or None
except Exception:
description = None
return EventFormData(
title=title,
start_date=start_date,
start_time=start_time,
end_date=end_date,
end_time=end_time,
location=location,
description=description,
calendar=calendar,
all_day=all_day,
)
def validate(self) -> tuple[bool, str]:
"""Validate the form data.
Returns:
Tuple of (is_valid, error_message)
"""
data = self.get_form_data()
if not data.title:
return False, "Title is required"
# Validate that end is after start
if data.end_datetime <= data.start_datetime:
return False, "End time must be after start time"
return True, ""
def submit(self) -> bool:
"""Validate and submit the form.
Returns:
True if form was valid and submitted, False otherwise
"""
is_valid, error = self.validate()
if not is_valid:
return False
self.post_message(self.Submitted(self.get_form_data()))
return True
def cancel(self) -> None:
"""Cancel the form."""
self.post_message(self.Cancelled())

View File

@@ -0,0 +1,242 @@
"""Mini month calendar widget for Calendar TUI sidebar.
Displays a compact month view with day numbers, highlighting:
- Today
- Current week
- Selected day
"""
from datetime import date, timedelta
from typing import Optional
from rich.segment import Segment
from rich.style import Style
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
from textual.widget import Widget
def get_month_calendar(year: int, month: int) -> list[list[Optional[date]]]:
"""Generate a calendar grid for a month.
Returns a list of weeks, where each week is a list of 7 dates (or None for empty cells).
Week starts on Monday.
"""
import calendar
# Get first day of month and number of days
first_day = date(year, month, 1)
if month == 12:
last_day = date(year + 1, 1, 1) - timedelta(days=1)
else:
last_day = date(year, month + 1, 1) - timedelta(days=1)
# Monday = 0, Sunday = 6
first_weekday = first_day.weekday()
weeks: list[list[Optional[date]]] = []
current_week: list[Optional[date]] = [None] * first_weekday
current = first_day
while current <= last_day:
current_week.append(current)
if len(current_week) == 7:
weeks.append(current_week)
current_week = []
current += timedelta(days=1)
# Fill remaining days in last week
if current_week:
while len(current_week) < 7:
current_week.append(None)
weeks.append(current_week)
return weeks
class MonthCalendar(Widget):
"""A compact month calendar widget for sidebars."""
DEFAULT_CSS = """
MonthCalendar {
width: 24;
height: auto;
padding: 0 1;
}
"""
# Reactive attributes
display_month: reactive[date] = reactive(lambda: date.today().replace(day=1))
selected_date: reactive[date] = reactive(date.today)
week_start: reactive[date] = reactive(lambda: date.today())
class DateSelected(Message):
"""A date was clicked/selected."""
def __init__(self, selected: date) -> None:
super().__init__()
self.date = selected
class MonthChanged(Message):
"""Month navigation occurred."""
def __init__(self, month: date) -> None:
super().__init__()
self.month = month
def __init__(
self,
selected_date: Optional[date] = None,
week_start: Optional[date] = None,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
if selected_date:
self.selected_date = selected_date
self.display_month = selected_date.replace(day=1)
if week_start:
self.week_start = week_start
def _get_theme_color(self, color_name: str) -> str:
"""Get a color from the current theme."""
try:
theme = self.app.current_theme
color = getattr(theme, color_name, None)
if color:
return str(color)
except Exception:
pass
# Fallback colors
fallbacks = {
"secondary": "#81A1C1",
"primary": "#88C0D0",
"accent": "#B48EAD",
"foreground": "#D8DEE9",
"surface": "#3B4252",
}
return fallbacks.get(color_name, "white")
@property
def _weeks(self) -> list[list[Optional[date]]]:
"""Get the weeks for the current display month."""
return get_month_calendar(self.display_month.year, self.display_month.month)
def get_content_height(self, container, viewport, width: int) -> int:
"""Calculate height: header + day names + weeks."""
return 2 + len(self._weeks) # Month header + day names + week rows
def render_line(self, y: int) -> Strip:
"""Render a line of the calendar."""
if y == 0:
return self._render_month_header()
elif y == 1:
return self._render_day_names()
else:
week_idx = y - 2
weeks = self._weeks
if 0 <= week_idx < len(weeks):
return self._render_week(weeks[week_idx])
return Strip.blank(self.size.width)
def _render_month_header(self) -> Strip:
"""Render the month/year header with navigation arrows."""
month_name = self.display_month.strftime("%B %Y")
header = f"< {month_name:^16} >"
header = header[: self.size.width].ljust(self.size.width)
primary_color = self._get_theme_color("primary")
style = Style(bold=True, color=primary_color)
return Strip([Segment(header, style)])
def _render_day_names(self) -> Strip:
"""Render the day name headers (Mo Tu We ...)."""
day_names = "Mo Tu We Th Fr Sa Su"
# Pad to widget width
line = day_names[: self.size.width].ljust(self.size.width)
style = Style(color="bright_black")
return Strip([Segment(line, style)])
def _render_week(self, week: list[Optional[date]]) -> Strip:
"""Render a week row."""
segments = []
today = date.today()
# Calculate the week containing week_start
week_end = self.week_start + timedelta(days=6)
secondary_color = self._get_theme_color("secondary")
primary_color = self._get_theme_color("primary")
for i, day in enumerate(week):
if day is None:
segments.append(Segment(" "))
else:
day_str = f"{day.day:2d} "
# Determine styling
if day == self.selected_date:
# Selected date - reverse video
style = Style(bold=True, reverse=True)
elif day == today:
# Today - highlighted with secondary color
style = Style(bold=True, color=secondary_color)
elif self.week_start <= day <= week_end:
# In current week view - subtle highlight
style = Style(color=primary_color)
elif day.weekday() >= 5:
# Weekend
style = Style(color="bright_black")
else:
# Normal day
style = Style()
segments.append(Segment(day_str, style))
# Pad remaining width
current_width = sum(len(s.text) for s in segments)
if current_width < self.size.width:
segments.append(Segment(" " * (self.size.width - current_width)))
return Strip(segments)
def update_week(self, week_start: date) -> None:
"""Update the current week highlight.
Also updates display_month if the week is in a different month.
"""
self.week_start = week_start
# Optionally auto-update display month to show the week
week_month = week_start.replace(day=1)
if week_month != self.display_month:
self.display_month = week_month
self.refresh()
def update_selected(self, selected: date) -> None:
"""Update the selected date."""
self.selected_date = selected
self.refresh()
def next_month(self) -> None:
"""Navigate to next month."""
year = self.display_month.year
month = self.display_month.month + 1
if month > 12:
month = 1
year += 1
self.display_month = date(year, month, 1)
self.post_message(self.MonthChanged(self.display_month))
self.refresh()
def prev_month(self) -> None:
"""Navigate to previous month."""
year = self.display_month.year
month = self.display_month.month - 1
if month < 1:
month = 12
year -= 1
self.display_month = date(year, month, 1)
self.post_message(self.MonthChanged(self.display_month))
self.refresh()

View File

@@ -0,0 +1,751 @@
"""Week view grid widget for Calendar TUI.
Displays a week of calendar events in a grid layout where:
- Columns represent days (5 or 7)
- Rows represent time slots (30 minutes per row)
- Events span multiple rows proportionally to their duration
"""
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import List, Optional, Tuple
from rich.style import Style
from rich.segment import Segment
from textual.binding import Binding
from textual.containers import Vertical
from textual.geometry import Size
from textual.message import Message
from textual.reactive import reactive
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widget import Widget
from src.calendar.backend import Event
from src.calendar import config
# Column widths
TIME_COLUMN_WIDTH = 6 # "HH:MM "
MIN_DAY_COLUMN_WIDTH = 10 # Minimum width for each day column
DEFAULT_DAY_COLUMN_WIDTH = 20 # Default/preferred width for each day column
def get_rows_per_hour() -> int:
"""Get rows per hour from config."""
return 60 // config.minutes_per_row()
def get_total_rows() -> int:
"""Get total rows for 24 hours."""
return 24 * get_rows_per_hour()
def get_week_start_for_date(target_date: date) -> date:
"""Get the week start date for a given date based on config.
Config uses: 0=Sunday, 1=Monday, ..., 6=Saturday
Python weekday() uses: 0=Monday, ..., 6=Sunday
"""
week_start_cfg = config.week_start_day() # 0=Sunday, 1=Monday, etc.
python_weekday = target_date.weekday() # 0=Monday, 6=Sunday
# Convert config week start to python weekday
# Sunday(0) -> 6, Monday(1) -> 0, Tuesday(2) -> 1, etc.
python_week_start = (week_start_cfg - 1) % 7
# Calculate days since week start
days_since_week_start = (python_weekday - python_week_start) % 7
return target_date - timedelta(days=days_since_week_start)
def get_day_column_for_date(target_date: date, week_start: date) -> int:
"""Get the column index for a date within its week.
Returns the number of days since week_start.
"""
return (target_date - week_start).days
@dataclass
class DayColumn:
"""Events and layout for a single day column."""
day: date
events: List[Event] = field(default_factory=list)
# 2D grid: row -> list of events at that row
grid: List[List[Event]] = field(default_factory=list)
def __post_init__(self):
# Initialize grid with rows for 24 hours
self.grid = [[] for _ in range(get_total_rows())]
def layout_events(self) -> None:
"""Layout events handling overlaps."""
total_rows = get_total_rows()
minutes_per_row = config.minutes_per_row()
# Clear the grid
self.grid = [[] for _ in range(total_rows)]
# Sort events by start time, then by duration (longer first)
sorted_events = sorted(
self.events, key=lambda e: (e.start, -(e.end - e.start).total_seconds())
)
for event in sorted_events:
if event.all_day:
continue # Handle all-day events separately
start_row, end_row = event.get_row_span(minutes_per_row)
# Clamp to valid range
start_row = max(0, min(start_row, total_rows - 1))
end_row = max(start_row + 1, min(end_row, total_rows))
# Add event to each row it spans
for row in range(start_row, end_row):
if event not in self.grid[row]:
self.grid[row].append(event)
class WeekGridHeader(Widget):
"""Fixed header widget showing day names."""
DEFAULT_CSS = """
WeekGridHeader {
height: 1;
background: $surface;
}
"""
def __init__(
self,
days: List[date],
cursor_col: int = 0,
include_weekends: bool = True,
name: Optional[str] = None,
id: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id)
self._days = days
self._cursor_col = cursor_col
self._include_weekends = include_weekends
def update_days(self, days: List[date], cursor_col: int) -> None:
"""Update the displayed days."""
self._days = days
self._cursor_col = cursor_col
self.refresh()
def set_include_weekends(self, include_weekends: bool) -> None:
"""Update the include_weekends setting."""
self._include_weekends = include_weekends
self.refresh()
@property
def num_days(self) -> int:
return 7 if self._include_weekends else 5
def _get_day_column_width(self) -> int:
"""Calculate day column width based on available space."""
available_width = self.size.width - TIME_COLUMN_WIDTH
if available_width <= 0 or self.num_days == 0:
return DEFAULT_DAY_COLUMN_WIDTH
width_per_day = available_width // self.num_days
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
def _get_theme_color(self, color_name: str) -> str:
"""Get a color from the current theme."""
try:
theme = self.app.current_theme
color = getattr(theme, color_name, None)
if color:
return str(color)
except Exception:
pass
# Fallback colors
fallbacks = {
"secondary": "#81A1C1",
"primary": "#88C0D0",
"foreground": "#D8DEE9",
"surface": "#3B4252",
}
return fallbacks.get(color_name, "white")
def render_line(self, y: int) -> Strip:
"""Render the header row."""
day_col_width = self._get_day_column_width()
if y != 0:
return Strip.blank(TIME_COLUMN_WIDTH + (day_col_width * self.num_days))
segments = []
# Time column spacer
segments.append(Segment(" " * TIME_COLUMN_WIDTH))
# Get theme colors
secondary_color = self._get_theme_color("secondary")
# Day headers
today = date.today()
for i, day in enumerate(self._days):
day_name = day.strftime("%a %m/%d")
# Style based on selection and today
if i == self._cursor_col:
style = Style(bold=True, reverse=True)
elif day == today:
# Highlight today with theme secondary color
style = Style(bold=True, color="white", bgcolor=secondary_color)
elif day.weekday() >= 5: # Weekend
style = Style(color="bright_black")
else:
style = Style()
# Center the day name in the column
header = day_name.center(day_col_width)
segments.append(Segment(header, style))
return Strip(segments)
class WeekGridBody(ScrollView):
"""Scrollable body of the week grid showing time slots and events."""
# Reactive attributes
cursor_row: reactive[int] = reactive(0)
cursor_col: reactive[int] = reactive(0)
# Messages
class CursorMoved(Message):
"""Cursor position changed."""
def __init__(self, row: int, col: int) -> None:
super().__init__()
self.row = row
self.col = col
def __init__(
self,
include_weekends: bool = True,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._days: List[DayColumn] = []
self._include_weekends = include_weekends
self._work_day_start = config.work_day_start_hour()
self._work_day_end = config.work_day_end_hour()
def _get_theme_color(self, color_name: str) -> str:
"""Get a color from the current theme."""
try:
theme = self.app.current_theme
color = getattr(theme, color_name, None)
if color:
return str(color)
except Exception:
pass
# Fallback colors
fallbacks = {
"secondary": "#81A1C1",
"primary": "#88C0D0",
"accent": "#B48EAD",
"foreground": "#D8DEE9",
"surface": "#3B4252",
"warning": "#EBCB8B",
"error": "#BF616A",
}
return fallbacks.get(color_name, "white")
@property
def num_days(self) -> int:
return 7 if self._include_weekends else 5
def _get_day_column_width(self) -> int:
"""Calculate day column width based on available space."""
available_width = self.size.width - TIME_COLUMN_WIDTH
if available_width <= 0 or self.num_days == 0:
return DEFAULT_DAY_COLUMN_WIDTH
width_per_day = available_width // self.num_days
return max(MIN_DAY_COLUMN_WIDTH, width_per_day)
@property
def content_width(self) -> int:
return TIME_COLUMN_WIDTH + (self._get_day_column_width() * self.num_days)
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
return get_total_rows()
def on_mount(self) -> None:
"""Set up virtual size for scrolling."""
self._update_virtual_size()
def _update_virtual_size(self) -> None:
"""Update the virtual size based on content dimensions."""
self.virtual_size = Size(self.content_width, get_total_rows())
def set_days(self, days: List[DayColumn]) -> None:
"""Set the day columns to display."""
self._days = days
self._update_virtual_size()
self.refresh()
def set_include_weekends(self, include_weekends: bool) -> None:
"""Update the include_weekends setting."""
self._include_weekends = include_weekends
self._update_virtual_size()
self.refresh()
def watch_cursor_row(self, old: int, new: int) -> None:
"""Handle cursor row changes."""
total_rows = get_total_rows()
# Clamp cursor row
if new < 0:
self.cursor_row = 0
elif new >= total_rows:
self.cursor_row = total_rows - 1
else:
# Scroll to keep cursor visible with a 2-row margin from viewport edges
self._scroll_to_keep_cursor_visible(new)
self.post_message(self.CursorMoved(new, self.cursor_col))
self.refresh()
def _scroll_to_keep_cursor_visible(self, cursor_row: int) -> None:
"""Scroll viewport only when cursor gets within 2 rows of the edge."""
margin = 2 # Number of rows to keep between cursor and viewport edge
scroll_y = int(self.scroll_offset.y)
viewport_height = self.size.height
# Calculate visible range
visible_top = scroll_y
visible_bottom = scroll_y + viewport_height - 1
# Check if cursor is too close to the top edge
if cursor_row < visible_top + margin:
# Scroll up to keep margin above cursor
new_scroll_y = max(0, cursor_row - margin)
self.scroll_to(y=new_scroll_y, animate=False)
# Check if cursor is too close to the bottom edge
elif cursor_row > visible_bottom - margin:
# Scroll down to keep margin below cursor
new_scroll_y = cursor_row - viewport_height + margin + 1
self.scroll_to(y=new_scroll_y, animate=False)
def watch_cursor_col(self, old: int, new: int) -> None:
"""Handle cursor column changes."""
self.post_message(self.CursorMoved(self.cursor_row, new))
self.refresh()
def render_line(self, y: int) -> Strip:
"""Render a single line of the grid."""
scroll_y = int(self.scroll_offset.y)
row_index = y + scroll_y
total_rows = get_total_rows()
if row_index < 0 or row_index >= total_rows:
return Strip.blank(self.content_width)
return self._render_time_row(row_index)
def _render_time_row(self, row_index: int) -> Strip:
"""Render a time row with events."""
rows_per_hour = get_rows_per_hour()
minutes_per_row = config.minutes_per_row()
segments = []
# Check if this is the current time row
now = datetime.now()
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
is_current_time_row = row_index == current_row
# Time label (only show on the hour)
if row_index % rows_per_hour == 0:
hour = row_index // rows_per_hour
time_str = f"{hour:02d}:00 "
else:
time_str = " " # Blank for half-hour
# Style time label - highlight current time, dim outside work hours
if is_current_time_row:
secondary_color = self._get_theme_color("secondary")
time_style = Style(color=secondary_color, bold=True)
elif (
row_index < self._work_day_start * rows_per_hour
or row_index >= self._work_day_end * rows_per_hour
):
time_style = Style(color="bright_black")
else:
primary_color = self._get_theme_color("primary")
time_style = Style(color=primary_color)
segments.append(Segment(time_str, time_style))
# Event cells for each day
for col_idx, day_col in enumerate(self._days):
cell_text, cell_style = self._render_event_cell(day_col, row_index, col_idx)
segments.append(Segment(cell_text, cell_style))
return Strip(segments)
def _render_event_cell(
self, day_col: DayColumn, row_index: int, col_idx: int
) -> Tuple[str, Style]:
"""Render a single cell for a day/time slot."""
events_at_row = day_col.grid[row_index] if row_index < len(day_col.grid) else []
rows_per_hour = get_rows_per_hour()
minutes_per_row = config.minutes_per_row()
day_col_width = self._get_day_column_width()
is_cursor = col_idx == self.cursor_col and row_index == self.cursor_row
if not events_at_row:
# Empty cell
if is_cursor:
return ">" + " " * (day_col_width - 1), Style(reverse=True)
else:
# Grid line style
if row_index % rows_per_hour == 0:
return "-" * day_col_width, Style(color="bright_black")
else:
return " " * day_col_width, Style()
# Get the event to display (first one if multiple)
event = events_at_row[0]
# Determine if this is the start row for this event
start_row, _ = event.get_row_span(minutes_per_row)
is_start = row_index == max(0, start_row)
# Build cell text
if is_start:
# Show event title with time
time_str = event.start.strftime("%H:%M")
title = event.title[: day_col_width - 7] # Leave room for time
cell_text = f"{time_str} {title}"
else:
# Continuation of event
cell_text = "" + event.title[: day_col_width - 3]
# Pad/truncate to column width
cell_text = cell_text[:day_col_width].ljust(day_col_width)
# Style based on event and cursor
if is_cursor:
style = Style(bold=True, reverse=True)
elif len(events_at_row) > 1:
# Overlapping events - use warning color
warning_color = self._get_theme_color("warning")
style = Style(bgcolor=warning_color, color="black")
else:
# Normal event - use primary color
primary_color = self._get_theme_color("primary")
style = Style(bgcolor=primary_color, color="black")
return cell_text, style
def get_event_at_cursor(self) -> Optional[Event]:
"""Get the event at the current cursor position."""
if self.cursor_col < 0 or self.cursor_col >= len(self._days):
return None
day_col = self._days[self.cursor_col]
if self.cursor_row < 0 or self.cursor_row >= len(day_col.grid):
return None
events = day_col.grid[self.cursor_row]
return events[0] if events else None
class WeekGrid(Vertical):
"""Week view calendar grid widget with fixed header."""
DEFAULT_CSS = """
WeekGrid {
height: 1fr;
}
WeekGridHeader {
height: 1;
}
WeekGridBody {
height: 1fr;
scrollbar-gutter: stable;
}
"""
BINDINGS = [
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("enter", "select_event", "View", show=True),
]
# Reactive attributes
week_start: reactive[date] = reactive(date.today)
include_weekends: reactive[bool] = reactive(True)
# Messages
class EventSelected(Message):
"""Event was selected."""
def __init__(self, event: Event) -> None:
super().__init__()
self.event = event
class WeekChanged(Message):
"""Week was changed via navigation."""
def __init__(self, week_start: date) -> None:
super().__init__()
self.week_start = week_start
def __init__(
self,
week_start: Optional[date] = None,
include_weekends: bool = True,
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
# Initialize state BEFORE setting reactive attributes
self._days: List[DayColumn] = []
self._events_by_date: dict[date, List[Event]] = {}
self._header: Optional[WeekGridHeader] = None
self._body: Optional[WeekGridBody] = None
# Set week start based on config.week_start_day() if not provided
if week_start is None:
today = date.today()
week_start = get_week_start_for_date(today)
self.include_weekends = include_weekends
self.week_start = week_start
@property
def num_days(self) -> int:
return 7 if self.include_weekends else 5
@property
def cursor_row(self) -> int:
"""Get current cursor row."""
if self._body:
return self._body.cursor_row
return 0
@cursor_row.setter
def cursor_row(self, value: int) -> None:
"""Set cursor row."""
if self._body:
self._body.cursor_row = value
@property
def cursor_col(self) -> int:
"""Get current cursor column."""
if self._body:
return self._body.cursor_col
return 0
@cursor_col.setter
def cursor_col(self, value: int) -> None:
"""Set cursor column."""
if self._body:
self._body.cursor_col = value
if self._header:
self._header.update_days([d.day for d in self._days], value)
def compose(self):
"""Compose the widget."""
days = [d.day for d in self._days] if self._days else []
self._header = WeekGridHeader(
days=days,
cursor_col=0,
include_weekends=self.include_weekends,
)
self._body = WeekGridBody(
include_weekends=self.include_weekends,
)
yield self._header
yield self._body
def on_mount(self) -> None:
"""Initialize on mount - set cursor to current day/time."""
self._init_week()
self.goto_now()
def _init_week(self) -> None:
"""Initialize the week's day columns."""
self._days = []
# Always iterate through 7 days from week_start
for i in range(7):
day = self.week_start + timedelta(days=i)
# Skip weekend days (Saturday=5, Sunday=6) when not including weekends
if not self.include_weekends and day.weekday() >= 5:
continue
col = DayColumn(day=day)
if day in self._events_by_date:
col.events = self._events_by_date[day]
col.layout_events()
self._days.append(col)
# Update child widgets
if self._header:
self._header.update_days(
[d.day for d in self._days], self._body.cursor_col if self._body else 0
)
if self._body:
self._body.set_days(self._days)
def set_events(self, events_by_date: dict[date, List[Event]]) -> None:
"""Set the events to display."""
self._events_by_date = events_by_date
self._init_week()
self.refresh()
def goto_now(self) -> None:
"""Set cursor to current day and time, scroll to work day start."""
today = date.today()
now = datetime.now()
rows_per_hour = get_rows_per_hour()
minutes_per_row = config.minutes_per_row()
# Set week to contain today using configurable week start day
week_start_date = get_week_start_for_date(today)
if self.week_start != week_start_date:
self.week_start = week_start_date
# Set cursor column to today (relative to week start)
col = get_day_column_for_date(today, self.week_start)
if not self.include_weekends and col >= 5:
col = 4 # Last weekday if weekend
self.cursor_col = col
# Set cursor row to current time
current_row = (now.hour * rows_per_hour) + (now.minute // minutes_per_row)
self.cursor_row = current_row
# Scroll to show work day start initially
if self._body:
work_start_row = config.work_day_start_hour() * rows_per_hour
# If current time is before work day start, scroll to work day start
# Otherwise scroll to show current time
scroll_target = min(work_start_row, current_row)
self._body.scroll_to(y=scroll_target, animate=False)
def watch_week_start(self, old: date, new: date) -> None:
"""Handle week_start changes."""
self._init_week()
self.post_message(self.WeekChanged(new))
self.refresh()
def watch_include_weekends(self, old: bool, new: bool) -> None:
"""Handle include_weekends changes."""
if self._header:
self._header.set_include_weekends(new)
if self._body:
self._body.set_include_weekends(new)
self._init_week()
self.refresh()
def on_week_grid_body_cursor_moved(self, message: WeekGridBody.CursorMoved) -> None:
"""Handle cursor moves in body - update header."""
if self._header:
self._header.update_days([d.day for d in self._days], message.col)
def get_event_at_cursor(self) -> Optional[Event]:
"""Get the event at the current cursor position."""
if self._body:
return self._body.get_event_at_cursor()
return None
def get_cursor_date(self) -> date:
"""Get the date at the current cursor column."""
if self._days and 0 <= self.cursor_col < len(self._days):
return self._days[self.cursor_col].day
return date.today()
def get_cursor_time(self):
"""Get the time at the current cursor row.
Returns:
A time object for the cursor row position.
"""
from datetime import time as time_type
minutes_per_row = config.minutes_per_row()
total_minutes = self.cursor_row * minutes_per_row
hour = total_minutes // 60
minute = total_minutes % 60
# Clamp to valid range
hour = max(0, min(23, hour))
minute = max(0, min(59, minute))
return time_type(hour, minute)
# Actions
def action_cursor_down(self) -> None:
"""Move cursor down."""
if self._body:
self._body.cursor_row += 1
def action_cursor_up(self) -> None:
"""Move cursor up."""
if self._body:
self._body.cursor_row -= 1
def action_cursor_left(self) -> None:
"""Move cursor left (wraps to previous week)."""
if self._body:
if self._body.cursor_col <= 0:
# Wrap to previous week
self._body.cursor_col = self.num_days - 1
self.action_prev_week()
else:
self._body.cursor_col -= 1
if self._header:
self._header.update_days(
[d.day for d in self._days], self._body.cursor_col
)
def action_cursor_right(self) -> None:
"""Move cursor right (wraps to next week)."""
if self._body:
if self._body.cursor_col >= self.num_days - 1:
# Wrap to next week
self._body.cursor_col = 0
self.action_next_week()
else:
self._body.cursor_col += 1
if self._header:
self._header.update_days(
[d.day for d in self._days], self._body.cursor_col
)
def action_prev_week(self) -> None:
"""Navigate to previous week."""
self.week_start = self.week_start - timedelta(weeks=1)
def action_next_week(self) -> None:
"""Navigate to next week."""
self.week_start = self.week_start + timedelta(weeks=1)
def action_goto_today(self) -> None:
"""Navigate to current week and today's column/time."""
self.goto_now()
def action_select_event(self) -> None:
"""Select the event at cursor."""
event = self.get_event_at_cursor()
if event:
self.post_message(self.EventSelected(event))

View File

@@ -0,0 +1,7 @@
"""Calendar TUI widgets."""
from .WeekGrid import WeekGrid
from .AddEventForm import AddEventForm, EventFormData
from .MonthCalendar import MonthCalendar
__all__ = ["WeekGrid", "AddEventForm", "EventFormData", "MonthCalendar"]