Files
luk/src/mail/screens/SearchPanel.py

301 lines
8.7 KiB
Python

"""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
"""
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
HIMALAYA_SEARCH_HELP = """
## Himalaya Search Query Syntax
A filter query is composed of operators and conditions:
### Operators
- `not <condition>` - filter envelopes that do NOT match the condition
- `<condition> and <condition>` - filter envelopes matching BOTH conditions
- `<condition> or <condition>` - filter envelopes matching EITHER condition
### Conditions
- `date <yyyy-mm-dd>` - match the given date
- `before <yyyy-mm-dd>` - date strictly before the given date
- `after <yyyy-mm-dd>` - date strictly after the given date
- `from <pattern>` - senders matching the pattern
- `to <pattern>` - recipients matching the pattern
- `subject <pattern>` - subject matching the pattern
- `body <pattern>` - text body matching the pattern
- `flag <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 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: auto;
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 = ""
def compose(self) -> ComposeResult:
with Horizontal():
yield Label("Search:", classes="search-label")
yield Input(
placeholder="from <name> or subject <text> or body <text>...",
id="search-input",
)
yield Label("", classes="search-status", id="search-status")
yield Button("?", variant="default", id="help-btn")
yield Button("Cancel", variant="warning", id="cancel-btn")
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
@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."""
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 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
# 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...")