Files
luk/src/mail/screens/SearchPanel.py
Bendt 560bc1d3bd Add date picker for search date/before/after keywords
- Add DatePickerModal using MonthCalendar widget from calendar app
- Detect when user types 'date ', 'before ', or 'after ' and show picker
- Insert selected date (YYYY-MM-DD format) into search input
- Support keyboard navigation (left/right for months, Enter to select)
- Today button for quick selection of current date
2025-12-19 15:45:15 -05:00

492 lines
15 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
- 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 <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 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 <name> or subject <text> or body <text>...",
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...")