Files
luk/src/calendar/widgets/WeekGrid.py

787 lines
27 KiB
Python

"""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
# Check if cursor is on this row
is_cursor_row = row_index == self.cursor_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 or cursor, dim outside work hours
if is_cursor_row:
# Highlight the hour label when cursor is on this row
primary_color = self._get_theme_color("primary")
time_style = Style(color=primary_color, bold=True, reverse=True)
elif is_current_time_row:
error_color = self._get_theme_color("error")
# Add subtle background to current time row for better visibility
surface_color = self._get_theme_color("surface")
time_style = Style(color=error_color, bold=True, bgcolor=surface_color)
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, is_current_time_row
)
segments.append(Segment(cell_text, cell_style))
return Strip(segments)
def _render_event_cell(
self,
day_col: DayColumn,
row_index: int,
col_idx: int,
is_current_time_row: bool = False,
) -> 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
# Get colors for current time line
error_color = self._get_theme_color("error") if is_current_time_row else None
if not events_at_row:
# Empty cell
if is_cursor:
return ">" + " " * (day_col_width - 1), Style(reverse=True)
elif is_current_time_row:
# Current time indicator line
return "" * day_col_width, Style(color=error_color, bold=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
# Always scroll to work day start initially (e.g., 7am)
if self._body:
work_start_row = config.work_day_start_hour() * rows_per_hour
self._body.scroll_to(y=work_start_row, 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))
def goto_date(self, target_date: date) -> None:
"""Navigate to a specific date.
Sets the week to contain the target date and places cursor on that day.
"""
# Get the week start for the target date
week_start_date = get_week_start_for_date(target_date)
if self.week_start != week_start_date:
self.week_start = week_start_date
# Set cursor column to the target date
col = get_day_column_for_date(target_date, self.week_start)
if not self.include_weekends and col >= 5:
col = 4 # Last weekday if weekend
self.cursor_col = col