Add calendar invites panel to Calendar TUI sidebar

- Create InvitesPanel widget showing pending invites from Microsoft Graph
- Add fetch_pending_invites() and respond_to_invite() API functions
- Invites load asynchronously in background on app mount
- Display invite subject, date/time, and organizer
- Add 'i' keybinding to focus invites panel
- Style: tentative invites shown in warning color
This commit is contained in:
Bendt
2025-12-19 10:51:15 -05:00
parent a82f001918
commit 3c45e2a154
4 changed files with 379 additions and 1 deletions

View File

@@ -0,0 +1,209 @@
"""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;
}
"""
# 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 _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()