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."""
"""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

179
src/utils/search.py Normal file
View File

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