"""Docked search panel for mail app with live search. Provides a search input docked to the top of the window with: - Live search with 1 second debounce - Cancel button to restore previous state - Help button showing Himalaya search syntax - Date picker for date/before/after keywords """ from datetime import date from typing import Optional, List, Dict, Any from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.message import Message from textual.reactive import reactive from textual.screen import ModalScreen from textual.timer import Timer from textual.widget import Widget from textual.widgets import Button, Input, Label, Static from textual.suggester import SuggestFromList from src.calendar.widgets.MonthCalendar import MonthCalendar # Himalaya search keywords for autocomplete HIMALAYA_KEYWORDS = [ "from ", "to ", "subject ", "body ", "date ", "before ", "after ", "flag ", "not ", "and ", "or ", "order by ", "order by date ", "order by date asc", "order by date desc", "order by from ", "order by to ", "order by subject ", "flag seen", "flag flagged", "not flag seen", ] HIMALAYA_SEARCH_HELP = """ ## Himalaya Search Query Syntax A filter query is composed of operators and conditions: ### Operators - `not ` - filter envelopes that do NOT match the condition - ` and ` - filter envelopes matching BOTH conditions - ` or ` - filter envelopes matching EITHER condition ### Conditions - `date ` - match the given date - `before ` - date strictly before the given date - `after ` - date strictly after the given date - `from ` - senders matching the pattern - `to ` - recipients matching the pattern - `subject ` - subject matching the pattern - `body ` - text body matching the pattern - `flag ` - envelopes with the given flag (e.g., `seen`, `flagged`) ### Examples - `from john` - emails from anyone named John - `subject meeting and after 2025-01-01` - meetings after Jan 1st - `not flag seen` - unread emails - `from boss or from manager` - emails from boss or manager - `body urgent and before 2025-12-01` - urgent emails before Dec 1st ### Sort Query Start with `order by`, followed by: - `date [asc|desc]` - `from [asc|desc]` - `to [asc|desc]` - `subject [asc|desc]` Example: `from john order by date desc` """.strip() class SearchHelpModal(ModalScreen[None]): """Modal showing Himalaya search syntax help.""" DEFAULT_CSS = """ SearchHelpModal { align: center middle; } SearchHelpModal > Vertical { width: 80; max-width: 90%; height: auto; max-height: 80%; border: solid $primary; background: $surface; padding: 1 2; } SearchHelpModal > Vertical > Static { height: auto; max-height: 100%; } SearchHelpModal > Vertical > Horizontal { height: auto; align: center middle; margin-top: 1; } """ BINDINGS = [ Binding("escape", "close", "Close"), ] def compose(self) -> ComposeResult: from textual.widgets import Markdown with Vertical(): yield Markdown(HIMALAYA_SEARCH_HELP) with Horizontal(): yield Button("Close", variant="primary", id="close-btn") def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "close-btn": self.dismiss(None) def action_close(self) -> None: self.dismiss(None) class DatePickerModal(ModalScreen[Optional[date]]): """Modal with a calendar for selecting a date.""" DEFAULT_CSS = """ DatePickerModal { align: center middle; } DatePickerModal > Vertical { width: 30; height: auto; border: solid $primary; background: $surface; padding: 1 2; } DatePickerModal > Vertical > Label { width: 100%; text-align: center; margin-bottom: 1; } DatePickerModal > Vertical > Horizontal { height: auto; align: center middle; margin-top: 1; } DatePickerModal > Vertical > Horizontal > Button { margin: 0 1; } """ BINDINGS = [ Binding("escape", "cancel", "Cancel"), Binding("left", "prev_month", "Previous month", show=False), Binding("right", "next_month", "Next month", show=False), Binding("enter", "select_date", "Select date", show=False), ] def __init__(self, keyword: str = "date") -> None: super().__init__() self.keyword = keyword def compose(self) -> ComposeResult: with Vertical(): yield Label(f"Select date for '{self.keyword}':", id="picker-title") yield MonthCalendar(id="date-picker-calendar") with Horizontal(): yield Button("Today", variant="default", id="today-btn") yield Button("Select", variant="primary", id="select-btn") yield Button("Cancel", variant="warning", id="cancel-btn") def on_month_calendar_date_selected( self, event: MonthCalendar.DateSelected ) -> None: """Handle date selection from calendar click.""" self.dismiss(event.date) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "select-btn": calendar = self.query_one("#date-picker-calendar", MonthCalendar) self.dismiss(calendar.selected_date) elif event.button.id == "today-btn": calendar = self.query_one("#date-picker-calendar", MonthCalendar) today = date.today() calendar.selected_date = today calendar.display_month = today.replace(day=1) calendar.refresh() elif event.button.id == "cancel-btn": self.dismiss(None) def action_cancel(self) -> None: self.dismiss(None) def action_prev_month(self) -> None: calendar = self.query_one("#date-picker-calendar", MonthCalendar) calendar.prev_month() def action_next_month(self) -> None: calendar = self.query_one("#date-picker-calendar", MonthCalendar) calendar.next_month() def action_select_date(self) -> None: calendar = self.query_one("#date-picker-calendar", MonthCalendar) self.dismiss(calendar.selected_date) class SearchPanel(Widget): """Docked search panel with live search capability.""" DEFAULT_CSS = """ SearchPanel { dock: top; height: auto; width: 100%; background: $surface; border-bottom: solid $primary; padding: 0 1; display: none; } SearchPanel.visible { display: block; } SearchPanel > Horizontal { height: 3; width: 100%; align: left middle; } SearchPanel .search-label { width: auto; padding: 0 1; color: $primary; } SearchPanel Input { width: 1fr; margin: 0 1; } SearchPanel Button { width: auto; min-width: 8; margin: 0 0 0 1; } SearchPanel .search-status { width: auto; padding: 0 1; color: $text-muted; } """ BINDINGS = [ Binding("escape", "cancel", "Cancel search", show=False), ] # Reactive to track search state is_searching: reactive[bool] = reactive(False) result_count: reactive[int] = reactive(-1) # -1 = no search yet class SearchRequested(Message): """Fired when a search should be performed.""" def __init__(self, query: str) -> None: super().__init__() self.query = query class SearchCancelled(Message): """Fired when the search is cancelled.""" pass class SearchConfirmed(Message): """Fired when user presses Enter to confirm search and focus results.""" def __init__(self, query: str) -> None: super().__init__() self.query = query def __init__( self, name: Optional[str] = None, id: Optional[str] = None, classes: Optional[str] = None, ) -> None: super().__init__(name=name, id=id, classes=classes) self._debounce_timer: Optional[Timer] = None self._last_query: str = "" self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date def compose(self) -> ComposeResult: with Horizontal(): yield Label("Search:", classes="search-label") yield Input( placeholder="from or subject or body ...", id="search-input", suggester=SuggestFromList(HIMALAYA_KEYWORDS, case_sensitive=False), ) yield Label("", classes="search-status", id="search-status") yield Button("?", variant="default", id="help-btn") yield Button("Cancel", variant="warning", id="cancel-btn") def _has_suggestion(self) -> bool: """Check if the search input currently has an autocomplete suggestion.""" try: input_widget = self.query_one("#search-input", Input) return bool(input_widget._suggestion and input_widget._cursor_at_end) except Exception: return False def _accept_suggestion(self) -> bool: """Accept the current autocomplete suggestion if present. Returns True if accepted.""" try: input_widget = self.query_one("#search-input", Input) if input_widget._suggestion and input_widget._cursor_at_end: input_widget.value = input_widget._suggestion input_widget.cursor_position = len(input_widget.value) return True except Exception: pass return False def on_key(self, event) -> None: """Handle key events to intercept Tab for autocomplete.""" if event.key == "tab": # Try to accept suggestion; if successful, prevent default tab behavior if self._accept_suggestion(): event.prevent_default() event.stop() def show(self, initial_query: str = "") -> None: """Show the search panel and focus the input.""" self.add_class("visible") input_widget = self.query_one("#search-input", Input) input_widget.value = initial_query self._last_query = initial_query input_widget.focus() def hide(self) -> None: """Hide the search panel.""" self.remove_class("visible") self._cancel_debounce() self.result_count = -1 def focus_input(self) -> None: """Focus the search input field.""" input_widget = self.query_one("#search-input", Input) input_widget.focus() @property def is_visible(self) -> bool: """Check if the panel is visible.""" return self.has_class("visible") @property def search_query(self) -> str: """Get the current search query.""" return self.query_one("#search-input", Input).value def _cancel_debounce(self) -> None: """Cancel any pending debounced search.""" if self._debounce_timer: self._debounce_timer.stop() self._debounce_timer = None def _trigger_search(self) -> None: """Trigger the actual search after debounce.""" # Don't search if an autocomplete suggestion is visible if self._has_suggestion(): return query = self.query_one("#search-input", Input).value.strip() if query and query != self._last_query: self._last_query = query self.is_searching = True self.post_message(self.SearchRequested(query)) def _check_date_keyword(self, value: str) -> Optional[str]: """Check if the input ends with a date keyword that needs a date picker. Returns the keyword (date/before/after) if found, None otherwise. """ value_lower = value.lower() for keyword in ("date ", "before ", "after "): if value_lower.endswith(keyword): return keyword.strip() return None def _show_date_picker(self, keyword: str) -> None: """Show the date picker modal for the given keyword.""" self._pending_date_keyword = keyword def on_date_selected(selected_date: Optional[date]) -> None: if selected_date: # Insert the date into the search input input_widget = self.query_one("#search-input", Input) date_str = selected_date.strftime("%Y-%m-%d") input_widget.value = input_widget.value + date_str input_widget.cursor_position = len(input_widget.value) self._pending_date_keyword = None # Refocus the input self.query_one("#search-input", Input).focus() self.app.push_screen(DatePickerModal(keyword), on_date_selected) def on_input_changed(self, event: Input.Changed) -> None: """Handle input changes with debounce.""" if event.input.id != "search-input": return # Cancel any existing timer self._cancel_debounce() # Don't search empty queries if not event.value.strip(): return # Check for date keywords and show picker date_keyword = self._check_date_keyword(event.value) if date_keyword: self._show_date_picker(date_keyword) return # Set up new debounce timer (1 second) self._debounce_timer = self.set_timer(1.0, self._trigger_search) def on_input_submitted(self, event: Input.Submitted) -> None: """Handle Enter key - trigger search immediately and confirm.""" if event.input.id != "search-input": return self._cancel_debounce() query = event.value.strip() if query: self._last_query = query self.is_searching = True self.post_message(self.SearchConfirmed(query)) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "cancel-btn": self.action_cancel() elif event.button.id == "help-btn": self.app.push_screen(SearchHelpModal()) def action_cancel(self) -> None: """Cancel the search and restore previous state.""" self._cancel_debounce() self.hide() self.post_message(self.SearchCancelled()) def update_status(self, count: int, searching: bool = False) -> None: """Update the search status display.""" self.is_searching = searching self.result_count = count status = self.query_one("#search-status", Label) if searching: status.update("Searching...") elif count >= 0: status.update(f"{count} result{'s' if count != 1 else ''}") else: status.update("") def watch_is_searching(self, searching: bool) -> None: """Update UI when searching state changes.""" status = self.query_one("#search-status", Label) if searching: status.update("Searching...")