"""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; } """ # 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.border_title = "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: header + invite rows.""" # Header (1) + invites (2 lines each) + empty message if no invites if not self.invites: return 2 # Header + "No pending invites" return 1 + len(self.invites) * 2 # Header + 2 lines per invite def render_line(self, y: int) -> Strip: """Render a line of the panel.""" if y == 0: return self._render_header() if not self.invites: if y == 1: return self._render_empty_message() return Strip.blank(self.size.width) # Each invite takes 2 lines invite_idx = (y - 1) // 2 line_in_invite = (y - 1) % 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_header(self) -> Strip: """Render the panel header.""" primary_color = self._get_theme_color("primary") count = len(self.invites) header = f"Invites ({count})" if count else "Invites" header = header[: self.size.width].ljust(self.size.width) style = Style(bold=True, color=primary_color) return Strip([Segment(header, style)]) 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.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 y == 0 or not self.invites: return # Calculate which invite was clicked invite_idx = (y - 1) // 2 if 0 <= invite_idx < len(self.invites): self.selected_index = invite_idx self.post_message(self.InviteSelected(self.invites[invite_idx])) self.refresh()