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