- 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
492 lines
15 KiB
Python
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...")
|