link panel
This commit is contained in:
248
src/maildir_gtd/widgets/EnvelopeListItem.py
Normal file
248
src/maildir_gtd/widgets/EnvelopeListItem.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""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;
|
||||
}
|
||||
|
||||
EnvelopeListItem .message-datetime {
|
||||
width: auto;
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-subject {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
EnvelopeListItem .email-preview {
|
||||
width: 1fr;
|
||||
padding: 0 3;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .sender-name {
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
EnvelopeListItem.unread .email-subject {
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user