Add live search panel with debounced Himalaya search and help modal
This commit is contained in:
174
src/mail/app.py
174
src/mail/app.py
@@ -4,6 +4,7 @@ from .widgets.ContentContainer import ContentContainer
|
||||
from .widgets.EnvelopeListItem import EnvelopeListItem, GroupHeader
|
||||
from .screens.LinkPanel import LinkPanel
|
||||
from .screens.ConfirmDialog import ConfirmDialog
|
||||
from .screens.SearchPanel import SearchPanel
|
||||
from .actions.task import action_create_task
|
||||
from .actions.open import action_open
|
||||
from .actions.delete import delete_current
|
||||
@@ -11,7 +12,6 @@ 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
|
||||
@@ -75,6 +75,8 @@ class EmailViewerApp(App):
|
||||
selected_messages: Reactive[set[int]] = reactive(set())
|
||||
main_content_visible: Reactive[bool] = reactive(True)
|
||||
search_query: Reactive[str] = reactive("") # Current search filter
|
||||
search_mode: Reactive[bool] = reactive(False) # True when showing search results
|
||||
_cached_envelopes: List[Dict[str, Any]] = [] # Cached envelopes before search
|
||||
|
||||
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
||||
yield from super().get_system_commands(screen)
|
||||
@@ -133,6 +135,7 @@ class EmailViewerApp(App):
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield SearchPanel(id="search_panel")
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
ListView(
|
||||
@@ -879,13 +882,30 @@ class EmailViewerApp(App):
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def action_clear_selection(self) -> None:
|
||||
"""Clear all selected messages."""
|
||||
"""Clear all selected messages and exit search mode."""
|
||||
if self.selected_messages:
|
||||
self.selected_messages.clear()
|
||||
self.refresh_list_view_items() # Refresh all items to uncheck checkboxes
|
||||
self._update_list_view_subtitle()
|
||||
if self.search_query:
|
||||
|
||||
# Exit search mode if active
|
||||
if self.search_mode:
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
search_panel.hide()
|
||||
self.search_mode = False
|
||||
self.search_query = ""
|
||||
|
||||
# Restore cached envelopes
|
||||
if self._cached_envelopes:
|
||||
self.message_store.envelopes = self._cached_envelopes
|
||||
self._cached_envelopes = []
|
||||
self._populate_list_view()
|
||||
|
||||
# Restore envelope list title
|
||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||
self.query_one(
|
||||
"#envelopes_list"
|
||||
).border_title = f"1️⃣ Emails {sort_indicator}"
|
||||
self._update_list_view_subtitle()
|
||||
|
||||
def action_oldest(self) -> None:
|
||||
@@ -897,32 +917,49 @@ class EmailViewerApp(App):
|
||||
self.show_message(self.message_store.get_newest_id())
|
||||
|
||||
def action_search(self) -> None:
|
||||
"""Open search dialog to search messages via Himalaya."""
|
||||
"""Open the search panel."""
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
if not search_panel.is_visible:
|
||||
# Cache current envelopes before searching
|
||||
self._cached_envelopes = list(self.message_store.envelopes)
|
||||
search_panel.show(self.search_query)
|
||||
|
||||
def handle_search_result(query: str | None) -> None:
|
||||
if query is None:
|
||||
return # User cancelled
|
||||
if not query.strip():
|
||||
# Empty query - clear search
|
||||
self.search_query = ""
|
||||
self._update_list_view_subtitle()
|
||||
return
|
||||
self.search_query = query
|
||||
self._perform_search(query)
|
||||
def on_search_panel_search_requested(
|
||||
self, event: SearchPanel.SearchRequested
|
||||
) -> None:
|
||||
"""Handle live search request from search panel."""
|
||||
self._perform_search(event.query, focus_results=False)
|
||||
|
||||
self.push_screen(
|
||||
SearchScreen(
|
||||
title="Search Messages",
|
||||
placeholder="Search by sender, recipient, subject, or body...",
|
||||
initial_value=self.search_query,
|
||||
),
|
||||
handle_search_result,
|
||||
)
|
||||
def on_search_panel_search_confirmed(
|
||||
self, event: SearchPanel.SearchConfirmed
|
||||
) -> None:
|
||||
"""Handle confirmed search (Enter key) - search and focus results."""
|
||||
self._perform_search(event.query, focus_results=True)
|
||||
|
||||
def on_search_panel_search_cancelled(
|
||||
self, event: SearchPanel.SearchCancelled
|
||||
) -> None:
|
||||
"""Handle search cancellation - restore previous envelope list."""
|
||||
self.search_mode = False
|
||||
self.search_query = ""
|
||||
|
||||
# Restore cached envelopes
|
||||
if self._cached_envelopes:
|
||||
self.message_store.envelopes = self._cached_envelopes
|
||||
self._cached_envelopes = []
|
||||
self._populate_list_view()
|
||||
|
||||
# Restore envelope list title
|
||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||
self.query_one("#envelopes_list").border_title = f"1️⃣ Emails {sort_indicator}"
|
||||
self._update_list_view_subtitle()
|
||||
self.query_one("#envelopes_list").focus()
|
||||
|
||||
@work(exclusive=True)
|
||||
async def _perform_search(self, query: str) -> None:
|
||||
"""Perform search using Himalaya and select first result."""
|
||||
self.show_status(f"Searching for '{query}'...")
|
||||
async def _perform_search(self, query: str, focus_results: bool = False) -> None:
|
||||
"""Perform search using Himalaya and display results in envelope list."""
|
||||
search_panel = self.query_one("#search_panel", SearchPanel)
|
||||
search_panel.update_status(-1, searching=True)
|
||||
|
||||
folder = self.folder if self.folder else None
|
||||
account = self.current_account if self.current_account else None
|
||||
@@ -932,43 +969,74 @@ class EmailViewerApp(App):
|
||||
)
|
||||
|
||||
if not success:
|
||||
search_panel.update_status(0, searching=False)
|
||||
self.show_status("Search failed", "error")
|
||||
return
|
||||
|
||||
# Update search panel status
|
||||
search_panel.update_status(len(results), searching=False)
|
||||
|
||||
if not results:
|
||||
self.show_status(f"No messages found matching '{query}'")
|
||||
# Clear the envelope list and show "no results"
|
||||
self._display_search_results([], query)
|
||||
return
|
||||
|
||||
# Get the first result's ID
|
||||
first_result = results[0]
|
||||
result_id = int(first_result.get("id", 0))
|
||||
self.search_query = query
|
||||
self.search_mode = True
|
||||
self._display_search_results(results, query)
|
||||
|
||||
if result_id == 0:
|
||||
self.show_status("Search returned invalid result", "error")
|
||||
return
|
||||
|
||||
# Find this ID in our current envelope list and select it
|
||||
metadata = self.message_store.get_metadata(result_id)
|
||||
if metadata:
|
||||
# Message is in current view - select it
|
||||
self.current_message_id = result_id
|
||||
self.current_message_index = metadata["index"]
|
||||
|
||||
# Update list view selection
|
||||
list_view = self.query_one("#envelopes_list", ListView)
|
||||
list_view.index = metadata["index"]
|
||||
|
||||
self.show_status(f"Found {len(results)} message(s) - showing first match")
|
||||
self.action_focus_4()
|
||||
else:
|
||||
# Message not in current view (maybe filtered or not loaded)
|
||||
# Just open it directly
|
||||
self.current_message_id = result_id
|
||||
self.show_status(
|
||||
f"Found {len(results)} message(s) - ID {result_id} (not in current list)"
|
||||
)
|
||||
if focus_results:
|
||||
# Focus the main content and select first result
|
||||
if results:
|
||||
first_id = int(results[0].get("id", 0))
|
||||
if first_id:
|
||||
self.current_message_id = first_id
|
||||
self.action_focus_4()
|
||||
|
||||
def _display_search_results(
|
||||
self, results: List[Dict[str, Any]], query: str
|
||||
) -> None:
|
||||
"""Display search results in the envelope list with a header."""
|
||||
envelopes_list = self.query_one("#envelopes_list", ListView)
|
||||
envelopes_list.clear()
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Add search results header
|
||||
header_label = f"Search: '{query}' ({len(results)} result{'s' if len(results) != 1 else ''})"
|
||||
envelopes_list.append(ListItem(GroupHeader(label=header_label)))
|
||||
|
||||
# Create a temporary message store for search results
|
||||
search_store = MessageStore()
|
||||
search_store.load(results, self.sort_order_ascending)
|
||||
|
||||
# Store for navigation (replace main store temporarily)
|
||||
self.message_store.envelopes = search_store.envelopes
|
||||
self.total_messages = len(results)
|
||||
|
||||
for item in search_store.envelopes:
|
||||
if item and item.get("type") == "header":
|
||||
envelopes_list.append(ListItem(GroupHeader(label=item["label"])))
|
||||
elif item:
|
||||
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,
|
||||
)
|
||||
envelopes_list.append(ListItem(envelope_widget))
|
||||
|
||||
# Update border title to show search mode
|
||||
sort_indicator = "↑" if self.sort_order_ascending else "↓"
|
||||
self.query_one(
|
||||
"#envelopes_list"
|
||||
).border_title = f"Search Results {sort_indicator}"
|
||||
|
||||
# Select first result if available
|
||||
if len(envelopes_list.children) > 1:
|
||||
envelopes_list.index = 1 # Skip header
|
||||
|
||||
def action_focus_1(self) -> None:
|
||||
self.query_one("#envelopes_list").focus()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user