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:
@@ -11,6 +11,7 @@ from src.services.taskwarrior import client as taskwarrior_client
|
|||||||
from src.services.himalaya import client as himalaya_client
|
from src.services.himalaya import client as himalaya_client
|
||||||
from src.utils.shared_config import get_theme_name
|
from src.utils.shared_config import get_theme_name
|
||||||
from src.utils.ipc import IPCListener, IPCMessage
|
from src.utils.ipc import IPCListener, IPCMessage
|
||||||
|
from src.utils.search import SearchScreen
|
||||||
from textual.containers import Container, ScrollableContainer, Vertical, Horizontal
|
from textual.containers import Container, ScrollableContainer, Vertical, Horizontal
|
||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
@@ -73,6 +74,7 @@ class EmailViewerApp(App):
|
|||||||
sort_order_ascending: Reactive[bool] = reactive(True)
|
sort_order_ascending: Reactive[bool] = reactive(True)
|
||||||
selected_messages: Reactive[set[int]] = reactive(set())
|
selected_messages: Reactive[set[int]] = reactive(set())
|
||||||
main_content_visible: Reactive[bool] = reactive(True)
|
main_content_visible: Reactive[bool] = reactive(True)
|
||||||
|
search_query: Reactive[str] = reactive("") # Current search filter
|
||||||
|
|
||||||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||||
yield from super().get_system_commands(screen)
|
yield from super().get_system_commands(screen)
|
||||||
@@ -126,6 +128,7 @@ class EmailViewerApp(App):
|
|||||||
Binding("x", "toggle_selection", "Toggle selection", show=False),
|
Binding("x", "toggle_selection", "Toggle selection", show=False),
|
||||||
Binding("space", "toggle_selection", "Toggle selection"),
|
Binding("space", "toggle_selection", "Toggle selection"),
|
||||||
Binding("escape", "clear_selection", "Clear selection"),
|
Binding("escape", "clear_selection", "Clear selection"),
|
||||||
|
Binding("/", "search", "Search"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -511,7 +514,13 @@ class EmailViewerApp(App):
|
|||||||
|
|
||||||
config = get_config()
|
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":
|
if item and item.get("type") == "header":
|
||||||
# Use the new GroupHeader widget for date groupings
|
# Use the new GroupHeader widget for date groupings
|
||||||
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
||||||
@@ -872,10 +881,17 @@ class EmailViewerApp(App):
|
|||||||
self._update_list_view_subtitle()
|
self._update_list_view_subtitle()
|
||||||
|
|
||||||
def action_clear_selection(self) -> None:
|
def action_clear_selection(self) -> None:
|
||||||
"""Clear all selected messages."""
|
"""Clear all selected messages and search filter."""
|
||||||
self.selected_messages.clear()
|
if self.selected_messages:
|
||||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
self.selected_messages.clear()
|
||||||
self._update_list_view_subtitle()
|
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:
|
def action_oldest(self) -> None:
|
||||||
self.fetch_envelopes() if self.reload_needed else 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.fetch_envelopes() if self.reload_needed else None
|
||||||
self.show_message(self.message_store.get_newest_id())
|
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:
|
def action_focus_1(self) -> None:
|
||||||
self.query_one("#envelopes_list").focus()
|
self.query_one("#envelopes_list").focus()
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,64 @@ class MessageStore:
|
|||||||
self.total_messages = len(self.metadata_by_id)
|
self.total_messages = len(self.metadata_by_id)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Invalid index {index} for message ID {message_id}")
|
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
179
src/utils/search.py
Normal 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
|
||||||
Reference in New Issue
Block a user