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

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