259 lines
7.7 KiB
Python
259 lines
7.7 KiB
Python
"""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")
|