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:
209
src/calendar/widgets/InvitesPanel.py
Normal file
209
src/calendar/widgets/InvitesPanel.py
Normal 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()
|
||||
Reference in New Issue
Block a user