"""Custom widget for rendering envelope list items with configurable display.""" from datetime import datetime from typing import Any, Dict, Optional from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.widgets import Label, Static from src.maildir_gtd.config import EnvelopeDisplayConfig, get_config class EnvelopeListItem(Static): """A widget for rendering a single envelope in the list. Supports configurable layout: - 2-line mode: sender/date on line 1, subject on line 2 - 3-line mode: adds a preview line Displays read/unread status with NerdFont icons. """ DEFAULT_CSS = """ EnvelopeListItem { height: auto; width: 1fr; padding: 0; } EnvelopeListItem .envelope-row-1 { height: 1; width: 1fr; } EnvelopeListItem .envelope-row-2 { height: 1; width: 1fr; } EnvelopeListItem .envelope-row-3 { height: 1; width: 1fr; } EnvelopeListItem .status-icon { width: 2; padding: 0 1 0 0; } EnvelopeListItem .checkbox { width: 2; padding: 0 1 0 0; } EnvelopeListItem .sender-name { width: 1fr; color: $text-muted; } EnvelopeListItem .message-datetime { width: auto; padding: 0 1; color: $text-disabled; } EnvelopeListItem .email-subject { width: 1fr; padding: 0 3; color: $text-muted; } EnvelopeListItem .email-preview { width: 1fr; padding: 0 3; color: $text-muted; } EnvelopeListItem.unread .sender-name { text-style: bold; color: $text; } EnvelopeListItem.unread .message-datetime { color: $text-muted; } EnvelopeListItem.unread .email-subject { text-style: bold; color: $text; } """ def __init__( self, envelope: Dict[str, Any], config: Optional[EnvelopeDisplayConfig] = None, is_selected: bool = False, **kwargs, ): """Initialize the envelope list item. Args: envelope: The envelope data dictionary from himalaya config: Display configuration (uses global config if not provided) is_selected: Whether this item is currently selected """ super().__init__(**kwargs) self.envelope = envelope self.config = config or get_config().envelope_display self._is_selected = is_selected # Parse envelope data self._parse_envelope() def _parse_envelope(self) -> None: """Parse envelope data into display-ready values.""" # Get sender info from_data = self.envelope.get("from", {}) self.sender_name = from_data.get("name") or from_data.get("addr", "Unknown") if not self.sender_name: self.sender_name = from_data.get("addr", "Unknown") # Truncate sender name if needed max_len = self.config.max_sender_length if len(self.sender_name) > max_len: self.sender_name = self.sender_name[: max_len - 1] + "\u2026" # ellipsis # Get subject self.subject = str(self.envelope.get("subject", "")).strip() or "(No subject)" # Parse date self.formatted_datetime = self._format_datetime() # Get read/unread status (himalaya uses "flags" field) flags = self.envelope.get("flags", []) self.is_read = "Seen" in flags if isinstance(flags, list) else False self.is_flagged = "Flagged" in flags if isinstance(flags, list) else False self.has_attachment = ( "Attachments" in flags if isinstance(flags, list) else False ) # Message ID for selection tracking self.message_id = int(self.envelope.get("id", 0)) def _format_datetime(self) -> str: """Format the message date/time according to config.""" date_str = self.envelope.get("date", "") if not date_str: return "" try: # Parse ISO format date if "Z" in date_str: date_str = date_str.replace("Z", "+00:00") dt = datetime.fromisoformat(date_str) # Convert to local timezone dt = dt.astimezone() parts = [] if self.config.show_date: parts.append(dt.strftime(self.config.date_format)) if self.config.show_time: parts.append(dt.strftime(self.config.time_format)) return " ".join(parts) except (ValueError, TypeError): return "Invalid Date" def compose(self) -> ComposeResult: """Compose the widget layout.""" # Determine status icon if self.is_read: status_icon = self.config.icon_read status_class = "status-icon read" else: status_icon = self.config.icon_unread status_class = "status-icon unread" # Add flagged/attachment indicators extra_icons = "" if self.is_flagged: extra_icons += f" {self.config.icon_flagged}" if self.has_attachment: extra_icons += f" {self.config.icon_attachment}" # Build the layout based on config.lines with Vertical(classes="envelope-content"): # Row 1: Status icon, checkbox, sender, datetime with Horizontal(classes="envelope-row-1"): yield Label(status_icon + extra_icons, classes=status_class) if self.config.show_checkbox: checkbox_char = "\uf4a7" if self._is_selected else "\ue640" yield Label(checkbox_char, classes="checkbox") yield Label(self.sender_name, classes="sender-name", markup=False) yield Label(self.formatted_datetime, classes="message-datetime") # Row 2: Subject with Horizontal(classes="envelope-row-2"): yield Label(self.subject, classes="email-subject", markup=False) # Row 3: Preview (only in 3-line mode with preview enabled) if self.config.lines == 3 and self.config.show_preview: preview = self.envelope.get("preview", "")[:60] if preview: with Horizontal(classes="envelope-row-3"): yield Label(preview, classes="email-preview", markup=False) def on_mount(self) -> None: """Set up classes on mount.""" if not self.is_read: self.add_class("unread") if self._is_selected: self.add_class("selected") def set_selected(self, selected: bool) -> None: """Update the selection state.""" self._is_selected = selected if selected: self.add_class("selected") else: self.remove_class("selected") # Update checkbox display if self.config.show_checkbox: try: checkbox = self.query_one(".checkbox", Label) checkbox.update("\uf4a7" if selected else "\ue640") except Exception: pass # Widget may not be mounted yet class GroupHeader(Static): """A header widget for grouping envelopes by date.""" DEFAULT_CSS = """ GroupHeader { height: 1; width: 1fr; background: $surface; color: $text-muted; text-style: bold; padding: 0 1; } """ def __init__(self, label: str, **kwargs): """Initialize the group header. Args: label: The header label (e.g., "Today", "December 2025") """ super().__init__(**kwargs) self.label = label def compose(self) -> ComposeResult: """Compose just returns itself as a Static.""" yield Label(self.label, classes="group-header-label")