link panel

This commit is contained in:
Bendt
2025-12-18 11:43:37 -05:00
parent 3640d143cf
commit e08f552386
11 changed files with 1126 additions and 120 deletions

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