- Mail: Fix folder/account selector not triggering reload (use direct fetch instead of reactive reload_needed flag) - Tasks: Store all_projects/all_tags on mount so filters don't change when filtering; add OR search for multiple tags - Sync: Use rounded borders and border_title for sidebar/activity log - Calendar: Remove padding from mini-calendar, add rounded border and border_title to invites panel
215 lines
6.9 KiB
Python
215 lines
6.9 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;
|
|
}
|
|
"""
|
|
|
|
# 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()
|