"""Calendar invites panel widget for Calendar TUI sidebar. Displays pending calendar invites that need a response. """ from dataclasses import dataclass from datetime import datetime from typing import List, 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 @dataclass class CalendarInvite: """A calendar invite pending response.""" id: str subject: str organizer: str start: datetime end: datetime location: Optional[str] = None is_all_day: bool = False response_status: str = "notResponded" # notResponded, tentativelyAccepted class InvitesPanel(Widget): """Panel showing pending calendar invites.""" DEFAULT_CSS = """ InvitesPanel { width: 100%; height: auto; min-height: 3; padding: 0 1; border: round $primary; border-title-color: $primary; } """ # Reactive attributes invites: reactive[List[CalendarInvite]] = reactive(list) selected_index: reactive[int] = reactive(0) class InviteSelected(Message): """An invite was selected.""" def __init__(self, invite: CalendarInvite) -> None: super().__init__() self.invite = invite class InviteRespond(Message): """User wants to respond to an invite.""" def __init__(self, invite: CalendarInvite, response: str) -> None: super().__init__() self.invite = invite self.response = response # accept, tentativelyAccept, decline def __init__( self, invites: Optional[List[CalendarInvite]] = None, name: Optional[str] = None, id: Optional[str] = None, classes: Optional[str] = None, ) -> None: super().__init__(name=name, id=id, classes=classes) if invites: self.invites = invites def on_mount(self) -> None: """Set border title on mount.""" self._update_border_title() def _update_border_title(self) -> None: """Update border title with invite count.""" count = len(self.invites) self.border_title = f"Invites ({count})" if count else "Invites" 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", } return fallbacks.get(color_name, "white") def get_content_height(self, container, viewport, width: int) -> int: """Calculate height: invite rows only (no internal header).""" if not self.invites: return 1 # "No pending invites" return len(self.invites) * 2 # 2 lines per invite def render_line(self, y: int) -> Strip: """Render a line of the panel.""" if not self.invites: if y == 0: return self._render_empty_message() return Strip.blank(self.size.width) # Each invite takes 2 lines invite_idx = y // 2 line_in_invite = y % 2 if 0 <= invite_idx < len(self.invites): return self._render_invite_line( self.invites[invite_idx], line_in_invite, invite_idx == self.selected_index, ) return Strip.blank(self.size.width) def _render_empty_message(self) -> Strip: """Render empty state message.""" msg = "No pending invites" msg = msg[: self.size.width].ljust(self.size.width) style = Style(color="bright_black") return Strip([Segment(msg, style)]) def _render_invite_line( self, invite: CalendarInvite, line: int, is_selected: bool ) -> Strip: """Render a line of an invite.""" if line == 0: # First line: subject (truncated) text = invite.subject[: self.size.width - 2] if is_selected: text = "> " + text[: self.size.width - 2] text = text[: self.size.width].ljust(self.size.width) if is_selected: style = Style(bold=True, reverse=True) else: style = Style() return Strip([Segment(text, style)]) else: # Second line: date/time and organizer date_str = invite.start.strftime("%m/%d %H:%M") organizer = invite.organizer[:15] if invite.organizer else "" info = f" {date_str} - {organizer}" info = info[: self.size.width].ljust(self.size.width) warning_color = self._get_theme_color("warning") if invite.response_status == "tentativelyAccepted": style = Style(color=warning_color, italic=True) else: style = Style(color="bright_black") return Strip([Segment(info, style)]) def set_invites(self, invites: List[CalendarInvite]) -> None: """Update the list of invites.""" self.invites = invites if self.selected_index >= len(invites): self.selected_index = max(0, len(invites) - 1) self._update_border_title() self.refresh() def select_next(self) -> None: """Select the next invite.""" if self.invites and self.selected_index < len(self.invites) - 1: self.selected_index += 1 self.refresh() def select_previous(self) -> None: """Select the previous invite.""" if self.invites and self.selected_index > 0: self.selected_index -= 1 self.refresh() def get_selected_invite(self) -> Optional[CalendarInvite]: """Get the currently selected invite.""" if self.invites and 0 <= self.selected_index < len(self.invites): return self.invites[self.selected_index] return None def on_click(self, event) -> None: """Handle mouse clicks.""" y = event.y if not self.invites: return # Calculate which invite was clicked (2 lines per invite) invite_idx = y // 2 if 0 <= invite_idx < len(self.invites): self.selected_index = invite_idx self.post_message(self.InviteSelected(self.invites[invite_idx])) self.refresh()