Add search functionality to Mail TUI with / keybinding

- Add reusable SearchScreen modal and ClearableSearchInput widget
- Implement filter_by_query in MessageStore for client-side filtering
- Search matches subject, sender name/email, recipient name/email
- Press / to open search, Escape to clear search filter
- Shows search query in list subtitle when filter is active
This commit is contained in:
Bendt
2025-12-19 11:01:05 -05:00
parent 3c45e2a154
commit 25385c6482
3 changed files with 295 additions and 5 deletions

View File

@@ -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()

View File

@@ -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