diff --git a/.coverage b/.coverage index c5b67e6..fd69aec 100644 Binary files a/.coverage and b/.coverage differ diff --git a/pyproject.toml b/pyproject.toml index f504ce8..e16b2a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,8 @@ dependencies = [ "openai>=1.78.1", "orjson>=3.10.18", "pillow>=11.2.1", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", "python-dateutil>=2.9.0.post0", "python-docx>=1.1.2", "requests>=2.31.0", @@ -40,6 +42,7 @@ dependencies = [ "textual>=3.2.0", "textual-image>=0.8.2", "ticktick-py>=2.0.0", + "toml>=0.10.0", ] [project.scripts] diff --git a/src/maildir_gtd/app.py b/src/maildir_gtd/app.py index accce56..7b051c3 100644 --- a/src/maildir_gtd/app.py +++ b/src/maildir_gtd/app.py @@ -1,5 +1,8 @@ +from .config import get_config, MaildirGTDConfig from .message_store import MessageStore from .widgets.ContentContainer import ContentContainer +from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader +from .screens.LinkPanel import LinkPanel from .actions.task import action_create_task from .actions.open import action_open from .actions.delete import delete_current @@ -102,6 +105,7 @@ class EmailViewerApp(App): Binding("q", "quit", "Quit application"), Binding("h", "toggle_header", "Toggle Envelope Header"), Binding("t", "create_task", "Create Task"), + Binding("l", "open_links", "Show Links"), Binding("%", "reload", "Reload message list"), Binding("1", "focus_1", "Focus Accounts Panel"), Binding("2", "focus_2", "Focus Folders Panel"), @@ -358,68 +362,27 @@ class EmailViewerApp(App): folders_list.loading = False def _populate_list_view(self) -> None: - """Populate the ListView with new items. This clears existing items.""" + """Populate the ListView with new items using the new EnvelopeListItem widget.""" envelopes_list = self.query_one("#envelopes_list", ListView) envelopes_list.clear() + config = get_config() + for item in self.message_store.envelopes: if item and item.get("type") == "header": - envelopes_list.append( - ListItem( - Container( - Label("", classes="checkbox"), # Hidden checkbox for header - Label( - item["label"], - classes="group_header", - markup=False, - ), - classes="envelope_item_row", - ) - ) + # Use the new GroupHeader widget for date groupings + envelopes_list.append(ListItem(GroupHeader(label=item["label"]))) + elif item: + # Use the new EnvelopeListItem widget + message_id = int(item.get("id", 0)) + is_selected = message_id in self.selected_messages + envelope_widget = EnvelopeListItem( + envelope=item, + config=config.envelope_display, + is_selected=is_selected, ) - elif item: # Check if not None - # Extract sender and date - sender_name = item.get("from", {}).get( - "name", item.get("from", {}).get("addr", "Unknown") - ) - if not sender_name: - sender_name = item.get("from", {}).get("addr", "Unknown") + envelopes_list.append(ListItem(envelope_widget)) - # Truncate sender name - max_sender_len = 25 # Adjust as needed - if len(sender_name) > max_sender_len: - sender_name = sender_name[: max_sender_len - 3] + "..." - - message_date_str = item.get("date", "") - formatted_date = "" - if message_date_str: - try: - # Parse the date string, handling potential timezone info - dt_object = datetime.fromisoformat(message_date_str) - formatted_date = dt_object.strftime("%m/%d %H:%M") - except ValueError: - formatted_date = "Invalid Date" - - list_item = ListItem( - Container( - Container( - Label("☐", classes="checkbox"), # Placeholder for checkbox - Label(sender_name, classes="sender_name"), - Label(formatted_date, classes="message_date"), - classes="envelope_header_row", - ), - Container( - Label( - str(item.get("subject", "")).strip(), - classes="email_subject", - markup=False, - ), - classes="envelope_subject_row", - ), - classes="envelope_item_row", - ) - ) - envelopes_list.append(list_item) self.refresh_list_view_items() # Initial refresh of item states def refresh_list_view_items(self) -> None: @@ -429,46 +392,17 @@ class EmailViewerApp(App): if isinstance(list_item, ListItem): item_data = self.message_store.envelopes[i] - # Find the checkbox label within the ListItem's children - # checkbox_label = list_item.query_one(".checkbox", Label) if item_data and item_data.get("type") != "header": message_id = int(item_data["id"]) is_selected = message_id in self.selected_messages or False list_item.set_class(is_selected, "selection") - # if checkbox_label: - # checkbox_label.update("\uf4a7" if is_selected else "\ue640") - # checkbox_label.display = True # Always display checkbox - - # list_item.highlighted = is_selected - - # # Update sender and date labels - # sender_name = item_data.get("from", {}).get("name", item_data.get("from", {}).get("addr", "Unknown")) - # if not sender_name: - # sender_name = item_data.get("from", {}).get("addr", "Unknown") - # max_sender_len = 25 - # if len(sender_name) > max_sender_len: - # sender_name = sender_name[:max_sender_len-3] + "..." - # list_item.query_one(".sender_name", Label).update(sender_name) - - # message_date_str = item_data.get("date", "") - # formatted_date = "" - # if message_date_str: - # try: - # dt_object = datetime.fromisoformat(message_date_str) - # formatted_date = dt_object.strftime("%m/%d %H:%M") - # except ValueError: - # formatted_date = "Invalid Date" - # list_item.query_one(".message_date", Label).update(formatted_date) - - # else: - # # For header items, checkbox should be unchecked and visible - # checkbox_label.update("\ue640") # Always unchecked for headers - # checkbox_label.display = True # Always display checkbox - # list_item.highlighted = False # Headers are never highlighted for selection - - # Update total messages count (this is still fine here) - # self.total_messages = self.message_store.total_messages + # Try to update the EnvelopeListItem's selection state + try: + envelope_widget = list_item.query_one(EnvelopeListItem) + envelope_widget.set_selected(is_selected) + except Exception: + pass # Widget may not exist or be of old type def show_message(self, message_id: int, new_index=None) -> None: if new_index: @@ -602,6 +536,12 @@ class EmailViewerApp(App): def action_create_task(self) -> None: action_create_task(self) + def action_open_links(self) -> None: + """Open the link panel showing links from the current message.""" + content_container = self.query_one(ContentContainer) + links = content_container.get_links() + self.push_screen(LinkPanel(links)) + def action_scroll_down(self) -> None: """Scroll the main content down.""" self.query_one("#main_content").scroll_down() @@ -644,15 +584,31 @@ class EmailViewerApp(App): message_id = int(current_item_data["id"]) envelopes_list = self.query_one("#envelopes_list", ListView) current_list_item = envelopes_list.children[self.highlighted_message_index] - checkbox_label = current_list_item.query_one(".checkbox", Label) + + # Toggle selection state if message_id in self.selected_messages: self.selected_messages.remove(message_id) - checkbox_label.remove_class("x-list") - checkbox_label.update("\ue640") + is_selected = False else: self.selected_messages.add(message_id) - checkbox_label.add_class("x-list") - checkbox_label.update("\uf4a7") + is_selected = True + + # Update the EnvelopeListItem widget + try: + envelope_widget = current_list_item.query_one(EnvelopeListItem) + envelope_widget.set_selected(is_selected) + except Exception: + # Fallback for old-style widgets + try: + checkbox_label = current_list_item.query_one(".checkbox", Label) + if is_selected: + checkbox_label.add_class("x-list") + checkbox_label.update("\uf4a7") + else: + checkbox_label.remove_class("x-list") + checkbox_label.update("\ue640") + except Exception: + pass self._update_list_view_subtitle() diff --git a/src/maildir_gtd/config.py b/src/maildir_gtd/config.py new file mode 100644 index 0000000..cc47a53 --- /dev/null +++ b/src/maildir_gtd/config.py @@ -0,0 +1,162 @@ +"""Configuration system for MaildirGTD email reader using Pydantic.""" + +import logging +import os +from pathlib import Path +from typing import Literal, Optional + +import toml +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class TaskBackendConfig(BaseModel): + """Configuration for task management backend.""" + + backend: Literal["taskwarrior", "dstask"] = "taskwarrior" + taskwarrior_path: str = "task" + dstask_path: str = Field( + default_factory=lambda: str(Path.home() / ".local" / "bin" / "dstask") + ) + + +class EnvelopeDisplayConfig(BaseModel): + """Configuration for envelope list item rendering.""" + + # Sender display + max_sender_length: int = 25 + + # Date/time display + date_format: str = "%m/%d" + time_format: str = "%H:%M" + show_date: bool = True + show_time: bool = True + + # Grouping + group_by: Literal["relative", "absolute"] = "relative" + # relative: "Today", "Yesterday", "This Week", etc. + # absolute: "December 2025", "November 2025", etc. + + # Layout + lines: Literal[2, 3] = 2 + # 2: sender/date on line 1, subject on line 2 + # 3: sender/date on line 1, subject on line 2, preview on line 3 + + show_checkbox: bool = True + show_preview: bool = False # Only used when lines=3 + + # NerdFont icons for status + icon_unread: str = "\uf0e0" # nf-fa-envelope (filled) + icon_read: str = "\uf2b6" # nf-fa-envelope_open (open) + icon_flagged: str = "\uf024" # nf-fa-flag + icon_attachment: str = "\uf0c6" # nf-fa-paperclip + + +class KeybindingsConfig(BaseModel): + """Keybinding customization.""" + + next_message: str = "j" + prev_message: str = "k" + delete: str = "#" + archive: str = "e" + open_by_id: str = "o" + quit: str = "q" + toggle_header: str = "h" + create_task: str = "t" + reload: str = "%" + toggle_sort: str = "s" + toggle_selection: str = "x" + clear_selection: str = "escape" + scroll_page_down: str = "space" + scroll_page_up: str = "b" + toggle_main_content: str = "w" + open_links: str = "l" + toggle_view_mode: str = "m" + + +class ContentDisplayConfig(BaseModel): + """Configuration for message content display.""" + + # View mode: "markdown" for pretty rendering, "html" for raw/plain display + default_view_mode: Literal["markdown", "html"] = "markdown" + + +class ThemeConfig(BaseModel): + """Theme/appearance settings.""" + + theme_name: str = "monokai" + + +class MaildirGTDConfig(BaseModel): + """Main configuration for MaildirGTD email reader.""" + + task: TaskBackendConfig = Field(default_factory=TaskBackendConfig) + envelope_display: EnvelopeDisplayConfig = Field( + default_factory=EnvelopeDisplayConfig + ) + content_display: ContentDisplayConfig = Field(default_factory=ContentDisplayConfig) + keybindings: KeybindingsConfig = Field(default_factory=KeybindingsConfig) + theme: ThemeConfig = Field(default_factory=ThemeConfig) + + @classmethod + def get_config_path(cls) -> Path: + """Get the path to the config file.""" + # Check environment variable first + env_path = os.getenv("MAILDIR_GTD_CONFIG") + if env_path: + return Path(env_path) + + # Default to ~/.config/luk/maildir_gtd.toml + return Path.home() / ".config" / "luk" / "maildir_gtd.toml" + + @classmethod + def load(cls, config_path: Optional[Path] = None) -> "MaildirGTDConfig": + """Load config from TOML file with defaults for missing values.""" + if config_path is None: + config_path = cls.get_config_path() + + if config_path.exists(): + try: + with open(config_path, "r") as f: + data = toml.load(f) + logger.info(f"Loaded config from {config_path}") + return cls.model_validate(data) + except Exception as e: + logger.warning(f"Error loading config from {config_path}: {e}") + logger.warning("Using default configuration") + return cls() + else: + logger.info(f"No config file at {config_path}, using defaults") + return cls() + + def save(self, config_path: Optional[Path] = None) -> None: + """Save current config to TOML file.""" + if config_path is None: + config_path = self.get_config_path() + + # Ensure parent directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, "w") as f: + toml.dump(self.model_dump(), f) + logger.info(f"Saved config to {config_path}") + + +# Global config instance (lazy-loaded) +_config: Optional[MaildirGTDConfig] = None + + +def get_config() -> MaildirGTDConfig: + """Get the global config instance, loading it if necessary.""" + global _config + if _config is None: + _config = MaildirGTDConfig.load() + return _config + + +def reload_config() -> MaildirGTDConfig: + """Force reload of the config from disk.""" + global _config + _config = MaildirGTDConfig.load() + return _config diff --git a/src/maildir_gtd/email_viewer.tcss b/src/maildir_gtd/email_viewer.tcss index 6f91026..8b58f34 100644 --- a/src/maildir_gtd/email_viewer.tcss +++ b/src/maildir_gtd/email_viewer.tcss @@ -70,6 +70,106 @@ Markdown { padding: 1 2; } +/* ===================================================== + NEW EnvelopeListItem and GroupHeader styles + ===================================================== */ + +/* EnvelopeListItem - the main envelope display widget */ +EnvelopeListItem { + height: auto; + width: 1fr; + padding: 0; +} + +EnvelopeListItem .envelope-content { + height: auto; + width: 1fr; +} + +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: 3; + padding: 0 1 0 0; + color: $text-muted; +} + +EnvelopeListItem .status-icon.unread { + color: $accent; +} + +EnvelopeListItem .checkbox { + width: 2; + padding: 0 1 0 0; +} + +EnvelopeListItem .sender-name { + width: 1fr; +} + +EnvelopeListItem .message-datetime { + width: auto; + padding: 0 1; + color: $secondary; +} + +EnvelopeListItem .email-subject { + width: 1fr; + padding: 0 4; +} + +EnvelopeListItem .email-preview { + width: 1fr; + padding: 0 4; + color: $text-muted; +} + +/* Unread message styling */ +EnvelopeListItem.unread .sender-name { + text-style: bold; +} + +EnvelopeListItem.unread .email-subject { + text-style: bold; +} + +/* Selected message styling */ +EnvelopeListItem.selected { + tint: $accent 20%; +} + +/* GroupHeader - date group separator */ +GroupHeader { + height: 1; + width: 1fr; + background: rgb(64, 62, 65); +} + +GroupHeader .group-header-label { + color: rgb(160, 160, 160); + text-style: bold; + padding: 0 1; + width: 1fr; +} + +/* ===================================================== + END NEW styles + ===================================================== */ + +/* Legacy styles (keeping for backward compatibility) */ .email_subject { width: 1fr; padding: 0 2; diff --git a/src/maildir_gtd/screens/LinkPanel.py b/src/maildir_gtd/screens/LinkPanel.py new file mode 100644 index 0000000..323dfe6 --- /dev/null +++ b/src/maildir_gtd/screens/LinkPanel.py @@ -0,0 +1,457 @@ +"""Link panel for viewing and opening URLs from email messages.""" + +import re +import webbrowser +from dataclasses import dataclass, field +from typing import List, Optional +from urllib.parse import urlparse + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Label, ListView, ListItem, Static + + +@dataclass +class LinkItem: + """Represents a link extracted from an email.""" + + url: str + label: str # Derived from anchor text or URL + domain: str # Extracted for display + short_display: str # Truncated/friendly display + context: str = "" # Surrounding text for context + mnemonic: str = "" # Quick-select key hint + + @classmethod + def from_url( + cls, + url: str, + anchor_text: str = "", + context: str = "", + max_display_len: int = 60, + ) -> "LinkItem": + """Create a LinkItem from a URL and optional anchor text.""" + parsed = urlparse(url) + domain = parsed.netloc.replace("www.", "") + + # Use anchor text as label if available, otherwise derive from URL + if anchor_text and anchor_text.strip(): + label = anchor_text.strip() + else: + # Try to derive a meaningful label from the URL path + label = cls._derive_label_from_url(parsed) + + # Create short display version + short_display = cls._shorten_url(url, domain, parsed.path, max_display_len) + + return cls( + url=url, + label=label, + domain=domain, + short_display=short_display, + context=context[:80] if context else "", + ) + + @staticmethod + def _derive_label_from_url(parsed) -> str: + """Derive a human-readable label from URL components.""" + path = parsed.path.strip("/") + if not path: + return parsed.netloc + + # Split path and take last meaningful segment + segments = [s for s in path.split("/") if s] + if segments: + last = segments[-1] + # Remove file extensions + last = re.sub(r"\.[a-zA-Z0-9]+$", "", last) + # Replace common separators with spaces + last = re.sub(r"[-_]", " ", last) + # Capitalize words + return last.title()[:40] + + return parsed.netloc + + @staticmethod + def _shorten_url(url: str, domain: str, path: str, max_len: int) -> str: + """Create a shortened, readable version of the URL.""" + # Special handling for common sites + path = path.strip("/") + + # GitHub: user/repo/issues/123 -> user/repo #123 + if "github.com" in domain: + match = re.match(r"([^/]+/[^/]+)/(issues|pull)/(\d+)", path) + if match: + repo, type_, num = match.groups() + icon = "#" if type_ == "issues" else "PR#" + return f"{domain} > {repo} {icon}{num}" + + match = re.match(r"([^/]+/[^/]+)", path) + if match: + return f"{domain} > {match.group(1)}" + + # Google Docs + if "docs.google.com" in domain: + if "/document/" in path: + return f"{domain} > Document" + if "/spreadsheets/" in path: + return f"{domain} > Spreadsheet" + if "/presentation/" in path: + return f"{domain} > Slides" + + # Jira/Atlassian + if "atlassian.net" in domain or "jira" in domain.lower(): + match = re.search(r"([A-Z]+-\d+)", path) + if match: + return f"{domain} > {match.group(1)}" + + # GitLab + if "gitlab" in domain.lower(): + match = re.match(r"([^/]+/[^/]+)/-/(issues|merge_requests)/(\d+)", path) + if match: + repo, type_, num = match.groups() + icon = "#" if type_ == "issues" else "MR!" + return f"{domain} > {repo} {icon}{num}" + + # Generic shortening + if len(url) <= max_len: + return url + + # Truncate path intelligently + path_parts = [p for p in path.split("/") if p] + if len(path_parts) > 2: + short_path = f"{path_parts[0]}/.../{path_parts[-1]}" + elif path_parts: + short_path = "/".join(path_parts) + else: + short_path = "" + + result = f"{domain}" + if short_path: + result += f" > {short_path}" + + if len(result) > max_len: + result = result[: max_len - 3] + "..." + + return result + + +def extract_links_from_content(content: str) -> List[LinkItem]: + """Extract all links from HTML or markdown content.""" + links: List[LinkItem] = [] + seen_urls: set = set() + + # Pattern for HTML links: text + html_pattern = r']*href=["\']([^"\']+)["\'][^>]*>([^<]*)' + for match in re.finditer(html_pattern, content, re.IGNORECASE): + url, anchor_text = match.groups() + if url and url not in seen_urls and _is_valid_url(url): + # Get surrounding context + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, anchor_text, context)) + seen_urls.add(url) + + # Pattern for markdown links: [text](url) + md_pattern = r"\[([^\]]+)\]\(([^)]+)\)" + for match in re.finditer(md_pattern, content): + anchor_text, url = match.groups() + if url and url not in seen_urls and _is_valid_url(url): + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, anchor_text, context)) + seen_urls.add(url) + + # Pattern for bare URLs + url_pattern = r'https?://[^\s<>"\'\)]+[^\s<>"\'\.\,\)\]]' + for match in re.finditer(url_pattern, content): + url = match.group(0) + if url not in seen_urls and _is_valid_url(url): + start = max(0, match.start() - 40) + end = min(len(content), match.end() + 40) + context = _clean_context(content[start:end]) + + links.append(LinkItem.from_url(url, "", context)) + seen_urls.add(url) + + # Assign mnemonic hints + _assign_mnemonics(links) + + return links + + +def _is_valid_url(url: str) -> bool: + """Check if a URL is valid and worth displaying.""" + if not url: + return False + + # Skip mailto, tel, javascript, etc. + if re.match(r"^(mailto|tel|javascript|data|#):", url, re.IGNORECASE): + return False + + # Skip very short URLs or fragments + if len(url) < 10: + return False + + # Must start with http/https + if not url.startswith(("http://", "https://")): + return False + + return True + + +def _clean_context(context: str) -> str: + """Clean up context string for display.""" + # Remove HTML tags + context = re.sub(r"<[^>]+>", "", context) + # Normalize whitespace + context = " ".join(context.split()) + return context.strip() + + +def _assign_mnemonics(links: List[LinkItem]) -> None: + """Assign unique mnemonic key hints to links.""" + used_mnemonics: set = set() + + # Characters to use for mnemonics (easily typeable) + # Exclude h,j,k,l to avoid conflicts with navigation keys + available_chars = "asdfgqwertyuiopzxcvbnm" + + for link in links: + mnemonic = None + + # Try first letter of domain + if link.domain: + first = link.domain[0].lower() + if first in available_chars and first not in used_mnemonics: + mnemonic = first + used_mnemonics.add(first) + + # Try first letter of label + if not mnemonic and link.label: + first = link.label[0].lower() + if first in available_chars and first not in used_mnemonics: + mnemonic = first + used_mnemonics.add(first) + + # Try first two letters combined logic + if not mnemonic: + # Try domain + label initials + candidates = [] + if link.domain and len(link.domain) > 1: + candidates.append(link.domain[:2].lower()) + if link.label: + words = link.label.split() + if len(words) >= 2: + candidates.append((words[0][0] + words[1][0]).lower()) + + for candidate in candidates: + if len(candidate) == 2 and candidate not in used_mnemonics: + # Check both chars are available + if all(c in available_chars for c in candidate): + mnemonic = candidate + used_mnemonics.add(candidate) + break + + # Fallback: find any unused character + if not mnemonic: + for char in available_chars: + if char not in used_mnemonics: + mnemonic = char + used_mnemonics.add(char) + break + + link.mnemonic = mnemonic or "" + + +class LinkListItem(Static): + """Widget for displaying a single link in the list.""" + + DEFAULT_CSS = """ + LinkListItem { + height: auto; + width: 1fr; + padding: 0 1; + } + """ + + def __init__(self, link: LinkItem, index: int, **kwargs): + super().__init__(**kwargs) + self.link = link + self.index = index + + def render(self) -> str: + """Render the link item using Rich markup.""" + mnemonic = self.link.mnemonic if self.link.mnemonic else "?" + # Line 1: [mnemonic] domain - label + line1 = ( + f"[bold cyan]\\[{mnemonic}][/] [dim]{self.link.domain}[/] {self.link.label}" + ) + # Line 2: shortened URL (indented) + line2 = f" [dim italic]{self.link.short_display}[/]" + return f"{line1}\n{line2}" + + +class LinkPanel(ModalScreen): + """Side panel for viewing and opening links from the current message.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Close"), + Binding("enter", "open_selected", "Open Link"), + Binding("j", "next_link", "Next"), + Binding("k", "prev_link", "Previous"), + ] + + DEFAULT_CSS = """ + LinkPanel { + align: right middle; + } + + LinkPanel #link-panel-container { + dock: right; + width: 50%; + min-width: 60; + max-width: 100; + height: 100%; + background: $surface; + border: round $primary; + padding: 1; + } + + LinkPanel #link-panel-container:focus-within { + border: round $accent; + } + + LinkPanel .link-panel-title { + text-style: bold; + padding: 0 0 1 0; + color: $text; + } + + LinkPanel .link-panel-hint { + color: $text-muted; + padding: 0 0 1 0; + } + + LinkPanel #link-list { + height: 1fr; + scrollbar-size: 1 1; + } + + LinkPanel #link-list > ListItem { + height: auto; + padding: 0; + } + + LinkPanel #link-list > ListItem:hover { + background: $boost; + } + + LinkPanel #link-list > ListItem.-highlight { + background: $accent 30%; + } + + LinkPanel .no-links-label { + color: $text-muted; + padding: 2; + text-align: center; + } + """ + + def __init__(self, links: List[LinkItem], **kwargs): + super().__init__(**kwargs) + self.links = links + self._mnemonic_map: dict[str, LinkItem] = { + link.mnemonic: link for link in links if link.mnemonic + } + + def compose(self) -> ComposeResult: + with Container(id="link-panel-container"): + yield Label("\uf0c1 Links", classes="link-panel-title") # nf-fa-link + yield Label( + "j/k: navigate, enter: open, esc: close", + classes="link-panel-hint", + ) + + if self.links: + with ListView(id="link-list"): + for i, link in enumerate(self.links): + yield ListItem(LinkListItem(link, i)) + else: + yield Label("No links found in this message.", classes="no-links-label") + + def on_mount(self) -> None: + self.query_one("#link-panel-container").border_title = "Links" + self.query_one( + "#link-panel-container" + ).border_subtitle = f"{len(self.links)} found" + if self.links: + self.query_one("#link-list").focus() + + def on_key(self, event) -> None: + """Handle mnemonic key presses.""" + key = event.key.lower() + + # Check for single-char mnemonic + if key in self._mnemonic_map: + self._open_link(self._mnemonic_map[key]) + event.prevent_default() + return + + # Check for two-char mnemonics (accumulate?) + # For simplicity, we'll just support single-char for now + # A more sophisticated approach would use a timeout buffer + + def action_open_selected(self) -> None: + """Open the currently selected link.""" + if not self.links: + return + + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and 0 <= link_list.index < len(self.links): + self._open_link(self.links[link_list.index]) + except Exception: + pass + + def action_next_link(self) -> None: + """Move to next link.""" + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and link_list.index < len(self.links) - 1: + link_list.index += 1 + except Exception: + pass + + def action_prev_link(self) -> None: + """Move to previous link.""" + try: + link_list = self.query_one("#link-list", ListView) + if link_list.index is not None and link_list.index > 0: + link_list.index -= 1 + except Exception: + pass + + def _open_link(self, link: LinkItem) -> None: + """Open a link in the default browser.""" + try: + webbrowser.open(link.url) + self.app.notify(f"Opened: {link.short_display}", title="Link Opened") + self.dismiss() + except Exception as e: + self.app.notify(f"Failed to open link: {e}", severity="error") + + @on(ListView.Selected) + def on_list_selected(self, event: ListView.Selected) -> None: + """Handle list item selection (Enter key or click).""" + if event.list_view.index is not None and 0 <= event.list_view.index < len( + self.links + ): + self._open_link(self.links[event.list_view.index]) diff --git a/src/maildir_gtd/screens/__init__.py b/src/maildir_gtd/screens/__init__.py index 8aeb3fd..9a4d624 100644 --- a/src/maildir_gtd/screens/__init__.py +++ b/src/maildir_gtd/screens/__init__.py @@ -2,5 +2,13 @@ from .CreateTask import CreateTaskScreen from .OpenMessage import OpenMessageScreen from .DocumentViewer import DocumentViewerScreen +from .LinkPanel import LinkPanel, LinkItem, extract_links_from_content -__all__ = ["CreateTaskScreen", "OpenMessageScreen", "DocumentViewerScreen"] +__all__ = [ + "CreateTaskScreen", + "OpenMessageScreen", + "DocumentViewerScreen", + "LinkPanel", + "LinkItem", + "extract_links_from_content", +] diff --git a/src/maildir_gtd/widgets/ContentContainer.py b/src/maildir_gtd/widgets/ContentContainer.py index a6c003d..8be874b 100644 --- a/src/maildir_gtd/widgets/ContentContainer.py +++ b/src/maildir_gtd/widgets/ContentContainer.py @@ -3,9 +3,13 @@ from textual import work from textual.binding import Binding from textual.containers import Vertical, ScrollableContainer from textual.widgets import Static, Markdown, Label +from textual.reactive import reactive from src.services.himalaya import client as himalaya_client +from src.maildir_gtd.config import get_config +from src.maildir_gtd.screens.LinkPanel import extract_links_from_content, LinkItem import logging from datetime import datetime +from typing import Literal, List import re import os import sys @@ -57,9 +61,15 @@ class EnvelopeHeader(Vertical): class ContentContainer(ScrollableContainer): + """Container for displaying email content with toggleable view modes.""" + can_focus = True + + # Reactive to track view mode and update UI + current_mode: reactive[Literal["markdown", "html"]] = reactive("markdown") + BINDINGS = [ - Binding("m", "toggle_mode", "Toggle View Mode") + Binding("m", "toggle_mode", "Toggle View Mode"), ] def __init__(self, **kwargs): @@ -68,34 +78,53 @@ class ContentContainer(ScrollableContainer): self.header = EnvelopeHeader(id="envelope_header") self.content = Markdown("", id="markdown_content") self.html_content = Static("", id="html_content", markup=False) - self.current_mode = "html" # Default to text mode self.current_content = None self.current_message_id = None self.content_worker = None + # Load default view mode from config + config = get_config() + self.current_mode = config.content_display.default_view_mode + def compose(self): yield self.content yield self.html_content def on_mount(self): - # Hide markdown content initially - # self.action_notify("loading message...") - self.content.styles.display = "none" - self.html_content.styles.display = "block" + # Set initial display based on config default + self._apply_view_mode() + self._update_mode_indicator() - async def action_toggle_mode(self): - """Toggle between plaintext and HTML viewing modes.""" - if self.current_mode == "html": - self.current_mode = "text" + def watch_current_mode(self, old_mode: str, new_mode: str) -> None: + """React to mode changes.""" + self._apply_view_mode() + self._update_mode_indicator() + + def _apply_view_mode(self) -> None: + """Apply the current view mode to widget visibility.""" + if self.current_mode == "markdown": self.html_content.styles.display = "none" self.content.styles.display = "block" else: - self.current_mode = "html" self.content.styles.display = "none" self.html_content.styles.display = "block" - # self.action_notify(f"switched to mode {self.current_mode}") + + def _update_mode_indicator(self) -> None: + """Update the border subtitle to show current mode.""" + mode_label = "Markdown" if self.current_mode == "markdown" else "HTML/Text" + mode_icon = ( + "\ue73e" if self.current_mode == "markdown" else "\uf121" + ) # nf-md-language_markdown / nf-fa-code + self.border_subtitle = f"{mode_icon} {mode_label}" + + async def action_toggle_mode(self): + """Toggle between markdown and HTML viewing modes.""" + if self.current_mode == "html": + self.current_mode = "markdown" + else: + self.current_mode = "html" + # Reload the content if we have a message ID - self.border_sibtitle = self.current_mode; if self.current_message_id: self.display_content(self.current_message_id) @@ -113,19 +142,17 @@ class ContentContainer(ScrollableContainer): if success: self._update_content(content) else: - self.notify( - f"Failed to fetch content for message ID {message_id}.") + self.notify(f"Failed to fetch content for message ID {message_id}.") def display_content(self, message_id: int) -> None: """Display the content of a message.""" - # self.action_notify(f"recieved message_id to display {message_id}") if not message_id: return self.current_message_id = message_id # Immediately show a loading message - if self.current_mode == "text": + if self.current_mode == "markdown": self.content.update("Loading...") else: self.html_content.update("Loading...") @@ -135,15 +162,20 @@ class ContentContainer(ScrollableContainer): self.content_worker.cancel() # Fetch content in the current mode - format_type = "text" if self.current_mode == "text" else "html" - self.content_worker = self.fetch_message_content( - message_id, format_type) + format_type = "text" if self.current_mode == "markdown" else "html" + self.content_worker = self.fetch_message_content(message_id, format_type) def _update_content(self, content: str | None) -> None: """Update the content widgets with the fetched content.""" + if content is None: + content = "(No content)" + + # Store the raw content for link extraction + self.current_content = content + try: - if self.current_mode == "text": - # For text mode, use the Markdown widget + if self.current_mode == "markdown": + # For markdown mode, use the Markdown widget self.content.update(content) else: # For HTML mode, use the Static widget with markup @@ -169,7 +201,13 @@ class ContentContainer(ScrollableContainer): except Exception as e: logging.error(f"Error updating content: {e}") - if self.current_mode == "text": + if self.current_mode == "markdown": self.content.update(f"Error displaying content: {e}") else: self.html_content.update(f"Error displaying content: {e}") + + def get_links(self) -> List[LinkItem]: + """Extract and return links from the current message content.""" + if not self.current_content: + return [] + return extract_links_from_content(self.current_content) diff --git a/src/maildir_gtd/widgets/EnvelopeListItem.py b/src/maildir_gtd/widgets/EnvelopeListItem.py new file mode 100644 index 0000000..ba775ae --- /dev/null +++ b/src/maildir_gtd/widgets/EnvelopeListItem.py @@ -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") diff --git a/src/maildir_gtd/widgets/__init__.py b/src/maildir_gtd/widgets/__init__.py index e6ab631..bc3c402 100644 --- a/src/maildir_gtd/widgets/__init__.py +++ b/src/maildir_gtd/widgets/__init__.py @@ -1 +1,6 @@ -# Initialize the screens subpackage +# Initialize the widgets subpackage +from .ContentContainer import ContentContainer +from .EnvelopeHeader import EnvelopeHeader +from .EnvelopeListItem import EnvelopeListItem, GroupHeader + +__all__ = ["ContentContainer", "EnvelopeHeader", "EnvelopeListItem", "GroupHeader"] diff --git a/uv.lock b/uv.lock index 0b4474c..83262fc 100644 --- a/uv.lock +++ b/uv.lock @@ -864,6 +864,8 @@ dependencies = [ { name = "openai" }, { name = "orjson" }, { name = "pillow" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-docx" }, { name = "requests" }, @@ -871,6 +873,7 @@ dependencies = [ { name = "textual" }, { name = "textual-image" }, { name = "ticktick-py" }, + { name = "toml" }, ] [package.dev-dependencies] @@ -899,6 +902,8 @@ requires-dist = [ { name = "openai", specifier = ">=1.78.1" }, { name = "orjson", specifier = ">=3.10.18" }, { name = "pillow", specifier = ">=11.2.1" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-docx", specifier = ">=1.1.2" }, { name = "requests", specifier = ">=2.31.0" }, @@ -906,6 +911,7 @@ requires-dist = [ { name = "textual", specifier = ">=3.2.0" }, { name = "textual-image", specifier = ">=0.8.2" }, { name = "ticktick-py", specifier = ">=2.0.0" }, + { name = "toml", specifier = ">=0.10.0" }, ] [package.metadata.requires-dev] @@ -1686,6 +1692,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pydub" version = "0.25.1" @@ -2126,6 +2146,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/cb/6291e38d14a52c73a4bf62a5cde88855741c1294f4a68cf38b46861d8480/ticktick_py-2.0.1-py3-none-any.whl", hash = "sha256:676c603322010ba9e508eda71698e917a3e2ba472bcfd26be2e5db198455fda5", size = 45675, upload-time = "2021-06-24T20:24:07.355Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tqdm" version = "4.67.1"