"""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