Files
luk/src/calendar/widgets/InvitesPanel.py
Bendt 3629757e70 Add context filter to Tasks TUI and fix calendar UI bugs
Tasks TUI:
- Add context support to TaskBackend interface (get_context, set_context,
  get_contexts methods)
- Implement context methods in DstaskClient
- Add Context section to FilterSidebar (above projects/tags)
- Context changes persist via backend CLI

Calendar TUI:
- Remove duplicate header from InvitesPanel (use border_title instead)
- Fix border_title color to use $primary
- Fix WeekGrid to always scroll to work day start (7am) on mount
2025-12-19 11:51:53 -05:00

209 lines
6.6 KiB
Python

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