diff --git a/src/mail/app.py b/src/mail/app.py index 6b426ea..ebbb3f0 100644 --- a/src/mail/app.py +++ b/src/mail/app.py @@ -11,6 +11,7 @@ from src.services.taskwarrior import client as taskwarrior_client from src.services.himalaya import client as himalaya_client from src.utils.shared_config import get_theme_name from src.utils.ipc import IPCListener, IPCMessage +from src.utils.search import SearchScreen from textual.containers import Container, ScrollableContainer, Vertical, Horizontal from textual.timer import Timer from textual.binding import Binding @@ -73,6 +74,7 @@ class EmailViewerApp(App): sort_order_ascending: Reactive[bool] = reactive(True) selected_messages: Reactive[set[int]] = reactive(set()) main_content_visible: Reactive[bool] = reactive(True) + search_query: Reactive[str] = reactive("") # Current search filter def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield from super().get_system_commands(screen) @@ -126,6 +128,7 @@ class EmailViewerApp(App): Binding("x", "toggle_selection", "Toggle selection", show=False), Binding("space", "toggle_selection", "Toggle selection"), Binding("escape", "clear_selection", "Clear selection"), + Binding("/", "search", "Search"), ] ) @@ -511,7 +514,13 @@ class EmailViewerApp(App): config = get_config() - for item in self.message_store.envelopes: + # Use filtered envelopes if search is active + if self.search_query: + display_envelopes = self.message_store.filter_by_query(self.search_query) + else: + display_envelopes = self.message_store.envelopes + + for item in display_envelopes: if item and item.get("type") == "header": # Use the new GroupHeader widget for date groupings envelopes_list.append(ListItem(GroupHeader(label=item["label"]))) @@ -872,10 +881,17 @@ class EmailViewerApp(App): self._update_list_view_subtitle() def action_clear_selection(self) -> None: - """Clear all selected messages.""" - self.selected_messages.clear() - self.refresh_list_view_items() # Refresh all items to uncheck checkboxes - self._update_list_view_subtitle() + """Clear all selected messages and search filter.""" + if self.selected_messages: + self.selected_messages.clear() + self.refresh_list_view_items() # Refresh all items to uncheck checkboxes + self._update_list_view_subtitle() + elif self.search_query: + # Clear search if no selection + self.search_query = "" + self._populate_list_view() + self._update_list_view_subtitle() + self.show_status("Search cleared") def action_oldest(self) -> None: self.fetch_envelopes() if self.reload_needed else None @@ -885,6 +901,40 @@ class EmailViewerApp(App): self.fetch_envelopes() if self.reload_needed else None self.show_message(self.message_store.get_newest_id()) + def action_search(self) -> None: + """Open search dialog to filter messages.""" + + def handle_search_result(query: str | None) -> None: + if query is None: + return # User cancelled + self.search_query = query + self._apply_search_filter() + + self.push_screen( + SearchScreen( + title="Search Messages", + placeholder="Search by subject, sender, or recipient...", + initial_value=self.search_query, + ), + handle_search_result, + ) + + def _apply_search_filter(self) -> None: + """Apply the current search filter to the envelope list.""" + self._populate_list_view() + + # Update the title to show search status + if self.search_query: + self.query_one("#envelopes_list").border_subtitle = f"[{self.search_query}]" + else: + self._update_list_view_subtitle() + + # Focus the list and select first message + self.query_one("#envelopes_list").focus() + envelopes_list = self.query_one("#envelopes_list", ListView) + if envelopes_list.children: + envelopes_list.index = 0 + def action_focus_1(self) -> None: self.query_one("#envelopes_list").focus() diff --git a/src/mail/message_store.py b/src/mail/message_store.py index 6335287..12be114 100644 --- a/src/mail/message_store.py +++ b/src/mail/message_store.py @@ -148,3 +148,64 @@ class MessageStore: self.total_messages = len(self.metadata_by_id) else: logging.warning(f"Invalid index {index} for message ID {message_id}") + + def filter_by_query(self, query: str) -> List[Dict[str, Any]]: + """Filter envelopes by search query. + + Searches subject, from name, from address, to name, and to address. + Returns a new list of filtered envelopes (with headers regenerated). + """ + if not query or not query.strip(): + return self.envelopes + + query_lower = query.lower().strip() + filtered = [] + current_month = None + + for item in self.envelopes: + if item is None: + continue + + # Skip headers - we'll regenerate them + if item.get("type") == "header": + continue + + # Check if envelope matches query + subject = item.get("subject", "").lower() + from_info = item.get("from", {}) + from_name = ( + from_info.get("name", "").lower() if isinstance(from_info, dict) else "" + ) + from_addr = ( + from_info.get("addr", "").lower() if isinstance(from_info, dict) else "" + ) + to_info = item.get("to", {}) + to_name = ( + to_info.get("name", "").lower() if isinstance(to_info, dict) else "" + ) + to_addr = ( + to_info.get("addr", "").lower() if isinstance(to_info, dict) else "" + ) + + if ( + query_lower in subject + or query_lower in from_name + or query_lower in from_addr + or query_lower in to_name + or query_lower in to_addr + ): + # Regenerate month header if needed + date_str = item.get("date", "") + try: + date = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + month_key = date.strftime("%B %Y") + except (ValueError, TypeError): + month_key = "Unknown Date" + + if month_key != current_month: + current_month = month_key + filtered.append({"type": "header", "label": month_key}) + + filtered.append(item) + + return filtered diff --git a/src/utils/search.py b/src/utils/search.py new file mode 100644 index 0000000..0891b65 --- /dev/null +++ b/src/utils/search.py @@ -0,0 +1,179 @@ +"""Reusable search input screen for TUI apps. + +A modal input dialog that can be used for search across all apps. +""" + +from typing import Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Input, Static, Label, Button + + +class SearchScreen(ModalScreen[str | None]): + """A modal screen for search input. + + Returns the search query string on submit, or None on cancel. + """ + + DEFAULT_CSS = """ + SearchScreen { + align: center middle; + } + + SearchScreen > Vertical { + width: 60; + height: auto; + border: solid $primary; + background: $surface; + padding: 1 2; + } + + SearchScreen > Vertical > Label { + margin-bottom: 1; + } + + SearchScreen > Vertical > Input { + margin-bottom: 1; + } + + SearchScreen > Vertical > Horizontal { + height: auto; + align: right middle; + } + + SearchScreen > Vertical > Horizontal > Button { + margin-left: 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel", show=True), + ] + + def __init__( + self, + title: str = "Search", + placeholder: str = "Enter search query...", + initial_value: str = "", + name: Optional[str] = None, + id: Optional[str] = None, + classes: Optional[str] = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self._title = title + self._placeholder = placeholder + self._initial_value = initial_value + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label(self._title) + yield Input( + placeholder=self._placeholder, + value=self._initial_value, + id="search-input", + ) + with Horizontal(): + yield Button("Search", variant="primary", id="search-btn") + yield Button("Cancel", variant="default", id="cancel-btn") + + def on_mount(self) -> None: + """Focus the input on mount.""" + self.query_one("#search-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle Enter key in input.""" + self.dismiss(event.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "search-btn": + query = self.query_one("#search-input", Input).value + self.dismiss(query) + elif event.button.id == "cancel-btn": + self.dismiss(None) + + def action_cancel(self) -> None: + """Cancel the search.""" + self.dismiss(None) + + +class ClearableSearchInput(Static): + """A search input widget with clear button for use in sidebars/headers. + + Emits SearchInput.Submitted message when user submits a query. + Emits SearchInput.Cleared message when user clears the search. + """ + + DEFAULT_CSS = """ + ClearableSearchInput { + height: 3; + padding: 0 1; + } + + ClearableSearchInput > Horizontal { + height: auto; + } + + ClearableSearchInput > Horizontal > Input { + width: 1fr; + } + + ClearableSearchInput > Horizontal > Button { + width: 3; + min-width: 3; + } + """ + + from textual.message import Message + + class Submitted(Message): + """Search query was submitted.""" + + def __init__(self, query: str) -> None: + super().__init__() + self.query = query + + class Cleared(Message): + """Search was cleared.""" + + pass + + def __init__( + self, + placeholder: str = "Search...", + name: Optional[str] = None, + id: Optional[str] = None, + classes: Optional[str] = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self._placeholder = placeholder + + def compose(self) -> ComposeResult: + with Horizontal(): + yield Input(placeholder=self._placeholder, id="search-input") + yield Button("X", id="clear-btn", variant="error") + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle search submission.""" + self.post_message(self.Submitted(event.value)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle clear button.""" + if event.button.id == "clear-btn": + input_widget = self.query_one("#search-input", Input) + input_widget.value = "" + input_widget.focus() + self.post_message(self.Cleared()) + + @property + def value(self) -> str: + """Get the current search value.""" + return self.query_one("#search-input", Input).value + + @value.setter + def value(self, new_value: str) -> None: + """Set the search value.""" + self.query_one("#search-input", Input).value = new_value