WIP
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user