"""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: 2; padding-top: 1; padding-right: 1; } AddEventForm .form-input { width: 1fr; } AddEventForm #title-input { width: 1fr; } AddEventForm .date-input { width: 18; } 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; height: 2; padding-top: 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())