381 lines
12 KiB
Python
381 lines
12 KiB
Python
"""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())
|