WIP
This commit is contained in:
6
src/calendar/__init__.py
Normal file
6
src/calendar/__init__.py
Normal 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
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()
|
||||
218
src/calendar/backend.py
Normal file
218
src/calendar/backend.py
Normal 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
110
src/calendar/config.py
Normal 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)
|
||||
155
src/calendar/screens/AddEventScreen.py
Normal file
155
src/calendar/screens/AddEventScreen.py
Normal 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)
|
||||
5
src/calendar/screens/__init__.py
Normal file
5
src/calendar/screens/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Calendar TUI screens."""
|
||||
|
||||
from .AddEventScreen import AddEventScreen
|
||||
|
||||
__all__ = ["AddEventScreen"]
|
||||
377
src/calendar/widgets/AddEventForm.py
Normal file
377
src/calendar/widgets/AddEventForm.py
Normal 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())
|
||||
242
src/calendar/widgets/MonthCalendar.py
Normal file
242
src/calendar/widgets/MonthCalendar.py
Normal 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()
|
||||
751
src/calendar/widgets/WeekGrid.py
Normal file
751
src/calendar/widgets/WeekGrid.py
Normal 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))
|
||||
7
src/calendar/widgets/__init__.py
Normal file
7
src/calendar/widgets/__init__.py
Normal 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"]
|
||||
Reference in New Issue
Block a user